Repository: hitzhangjie/golang-debugger-book Branch: master Commit: 061471090217 Files: 165 Total size: 1.0 MB Directory structure: gitextract_wz0yy6cs/ ├── .cursor/ │ ├── commands/ │ │ ├── gg_catalogue.md │ │ └── gg_commit.md │ └── rules/ │ ├── rule-heading.mdc │ ├── rule-summary.mdc │ └── rule-translate.mdc ├── .github/ │ └── workflows/ │ ├── SETUP_GITHUB_ACTIONS.md │ └── deploy.yml ├── .gitignore ├── .vscode/ │ ├── bookmarks.json │ └── settings.json ├── Makefile ├── README.md ├── book/ │ ├── 10-extras/ │ │ ├── 1-development-of-debugger.md │ │ ├── 2-development-of-logging.md │ │ ├── 3-development-of-metrics.md │ │ ├── 4-development-of-tracing.md │ │ ├── 5-development-of-bisect.md │ │ ├── 6-development-of-ebpf.md │ │ ├── 7-development-of-replay.md │ │ ├── 8-development-of-debug-dsys.md │ │ ├── 9-ai-assisted-debugging.md │ │ └── README.md │ ├── 11-thanks/ │ │ └── README.md │ ├── 12-appendix/ │ │ ├── 1-go-programme-start.md │ │ ├── 2-auto-trace-cloned-threads.md │ │ ├── 3-git-bisect.md │ │ ├── 4-vscode-extension-bisect.md │ │ ├── 80-go-tool-compile.md │ │ ├── 81-go-tool-asm.md │ │ ├── 82-go-tool-link.md │ │ ├── 90-why-buildid-loaded.md │ │ ├── 91-syntax-and-semantic-analysis.md │ │ ├── 92-why-gdb-uses-symtab.md │ │ └── README.md │ ├── 2-preface/ │ │ └── README.md │ ├── 3-terms/ │ │ └── README.md │ ├── 4-basics/ │ │ ├── 1-purposes.md │ │ ├── 2-dependencies.md │ │ ├── 3-countertactics.md │ │ └── README.md │ ├── 5-debugger-skeleton/ │ │ ├── 1-debugger_skeleton.md │ │ ├── 2-debugger_solution.md │ │ ├── 3-debugger_solution_final.md │ │ ├── 4-debugger_demos.md │ │ └── README.md │ ├── 6-develop-inst-debugger/ │ │ ├── 1-process_start.md │ │ ├── 10-clearall-atexit.md │ │ ├── 11-step.md │ │ ├── 12-continue.md │ │ ├── 13-pmem.md │ │ ├── 14-pregs.md │ │ ├── 15-set-mem.md │ │ ├── 16-set-regs.md │ │ ├── 2-process_attach.md │ │ ├── 20-multithread/ │ │ │ ├── 1-trace_old_threads.md │ │ │ ├── 2-trace_new_threads.md │ │ │ ├── 3-suspend_policy.md │ │ │ ├── 4-continue.md │ │ │ ├── 5-breakpoint.md │ │ │ └── README.md │ │ ├── 21-multiprocess/ │ │ │ └── README.md │ │ ├── 3-process_start_attach.md │ │ ├── 4-debug-session.md │ │ ├── 5-disassemble.md │ │ ├── 6-breakpoint.md │ │ ├── 7-breakpoints.md │ │ ├── 8-clear.md │ │ ├── 80-aslr.md │ │ ├── 9-clearall.md │ │ ├── 99-more.md │ │ └── README.md │ ├── 7-headto-sym-debugger/ │ │ ├── 1-how-go-build-works.md │ │ ├── 2-elf.md │ │ ├── 3-syms.md │ │ ├── 4-syms-resolve-reloc.md │ │ ├── 5-loading.md │ │ ├── 6-gopkg-debug/ │ │ │ ├── 1-elf.md │ │ │ ├── 2-gosym.md │ │ │ ├── 3-dwarf.md │ │ │ └── README.md │ │ ├── 7-headto-dwarf/ │ │ │ ├── 1-gopkgs-about-dwarf.md │ │ │ ├── 2-delve-into-internals.md │ │ │ ├── 3-compiler-gen-dwarfdata.md │ │ │ ├── 4-linker-gen-dwarfdata.md │ │ │ ├── 5-headto-dwarf.md │ │ │ └── README.md │ │ └── README.md │ ├── 8-dwarf/ │ │ ├── 1-history.md │ │ ├── 2-overview.md │ │ ├── 3-dwarfdata.md │ │ ├── 4-die/ │ │ │ ├── 1-desc-data-type.md │ │ │ ├── 2-desc-locations.md │ │ │ ├── 3-desc-code.md │ │ │ ├── 4-encoding.md │ │ │ └── README.md │ │ ├── 5-other/ │ │ │ ├── 1-accelerated-access.md │ │ │ ├── 2-lineno-table.md │ │ │ ├── 3-callframe-info.md │ │ │ ├── 4-macro-info.md │ │ │ ├── 5-varlen-data.md │ │ │ ├── 6-shrink-data.md │ │ │ ├── 7-elf-sections.md │ │ │ └── README.md │ │ ├── 6-practices.md │ │ ├── 7-summary.md │ │ └── README.md │ ├── 9-develop-sym-debugger/ │ │ ├── 1-架构设计/ │ │ │ ├── 1-现代调试器架构.md │ │ │ ├── 2-前端UI层设计.md │ │ │ ├── 3-Service层设计.md │ │ │ ├── 4-后端符号层设计.md │ │ │ ├── 5-后端目标层设计.md │ │ │ ├── 6-日志系统设计.md │ │ │ └── README.md │ │ ├── 2-核心调试逻辑/ │ │ │ ├── 00-cmds.md │ │ │ ├── 01-debug-session.md │ │ │ ├── 100-debug_goroutines.md │ │ │ ├── 100-how_listfunctions_work.md │ │ │ ├── 100-multi-threads.md │ │ │ ├── 11-tinydbg_attach.md │ │ │ ├── 12-tinydbg_attach_waitfor.md │ │ │ ├── 13-tinydbg_exec.md │ │ │ ├── 14-tinydbg_debug.md │ │ │ ├── 15-tinydbg_core1.md │ │ │ ├── 15-tinydbg_core2.md │ │ │ ├── 16-tinydbg_connect.md │ │ │ ├── 17-tinydbg_trace.md │ │ │ ├── 19-how_evalexpr_works.md │ │ │ ├── 20-how_locspec_works.md │ │ │ ├── 21-debug_disassemble.md │ │ │ ├── 22-debug_breakpoint_part1.md │ │ │ ├── 23-debug_breakpoint_part2.md │ │ │ ├── 24-debug_breakpoint_part3.md │ │ │ ├── 25-debug_breakpoint_part4.md │ │ │ ├── 26-debug_trace.md │ │ │ ├── 27-debug_watch.md │ │ │ ├── 28-debug_continue.md │ │ │ ├── 29-debug_next.md │ │ │ ├── 30-debug_step.md │ │ │ ├── 31-debug_pmem.md │ │ │ ├── 32-debug_regs.md │ │ │ ├── 33-debug_funcs.md │ │ │ ├── 34-debug_vars.md │ │ │ ├── 35-debug_bt.md │ │ │ └── README.md │ │ ├── 3-高级功能扩展/ │ │ │ ├── 100-howto_integrate_starlark.md │ │ │ ├── 101-howto_tracing_via_ebpf.md │ │ │ ├── 102-howto_syntax_highlight.md │ │ │ ├── 103-howto_paging_output.md │ │ │ ├── 104-howto_read_separate_dwarfdata.md │ │ │ ├── 105-howto_guess_substitutepath.md │ │ │ ├── 106-howto_redirect_target_io.md │ │ │ ├── 107-howto_customize_tinydbg.md │ │ │ ├── 108-howto_accept_multiclient.md │ │ │ ├── 109-howto_transcript_debugging.md │ │ │ ├── 110-howto_debug_deterministically.md │ │ │ ├── 111-howto-integrate-with-rr.md │ │ │ └── README.md │ │ └── README.md │ ├── README.md │ ├── SUMMARY.md │ └── book.json ├── changelog.md ├── contributing.md ├── contributing.zh_CN.md └── deploy.sh ================================================ FILE CONTENTS ================================================ ================================================ FILE: .cursor/commands/gg_catalogue.md ================================================ # gg_catalogue `/gg_catalogue` 是一个命令,用于更新目录操作(包括目录中的引用的文件路径以及显示的章节名称),需要执行以下步骤: 1. 确定当前有变更的文件列表(比如通过git status) 2. 如果是新增文件,记得在目录文件SUMMARY.md中添加对应的章节 3. 如果是删除文件,记得在目录文件SUMMARY.md中删除对应的章节 4. 如果是重命名文件,需要先确定旧文件的名称及SUMMARY.md中的章节名称,然后更新为新的文件名称及章节名称 ## 注意事项 - 确保在更新目录前已经保存了所有文件变更 - 检查SUMMARY.md的格式是否正确 - 保持章节顺序的逻辑性和连贯性 This command will be available in chat with `/gg_catalogue` ================================================ FILE: .cursor/commands/gg_commit.md ================================================ # gg_commit `/gg_commit [message]` is a command to commit staged changes to the repository. ## Usage ```bash /gg_commit [message] ``` ## Behavior 1. **With message provided**: Commits the staged changes with the provided message. - Example: `/gg_commit Fix bug in debugger initialization` 2. **Without message**: Automatically generates a commit message by: - Running `git diff --cached` to analyze staged changes - Summarizing the changes into an appropriate commit message - Committing with the generated message ## Workflow 1. Stage your changes using `git add` (if not already staged) 2. Run `/gg_commit` with or without a message 3. The command will create a commit with the appropriate message ## Notes - Only commits staged changes (files added via `git add`) - Does not push changes to remote repository - Automatically generates meaningful commit messages when none provided - Follows conventional commit message format when possible This command will be available in chat with `/gg_commit` ================================================ FILE: .cursor/rules/rule-heading.mdc ================================================ --- description: 标题级别设置 globs: *.md alwaysApply: true --- 文档起始标题统一设置为二级标题,即 `##`。 ================================================ FILE: .cursor/rules/rule-summary.mdc ================================================ --- description: 本节小结应该如何写 globs: *.md alwaysApply: true --- 本节小结是对当前章节核心内容的提炼,需帮助读者回顾重点、理清逻辑,并为后续内容做好铺垫。作用包括强化主要印象、梳理知识逻辑、解答潜在疑惑、预示下文方向、突出创新价值。 以下是个本节小结的示例: ```markdown ### 本节小结 本节主要探讨了{什么内容},核心内容包括:{要点1};{要点2};{要点3}。本节内容为读者学习{后续内容}打下了{基础}。 ``` ================================================ FILE: .cursor/rules/rule-translate.mdc ================================================ --- description: 文章翻译设置 globs: *.md alwaysApply: true --- 当要求将文章内容翻译为中文时,或者要求对文章内容进行润色时,要注意: - 避免生硬,要结合作者文风以及中国人的说话习惯,翻译为地道的中国普通话。 - 分段翻译,如果文章内容过长,可以逐一对段落进行翻译,也方便用户review译文或者润色后的质量。 - 特殊内容,如代码、代码注释、英文论文或者书籍名称、英文作者名称、链接地址等,不进行翻译。 - 专有名词,专业属于,翻译为中文时注意保留原英文,可以采取这样的格式 “译文(英文)”。 ================================================ FILE: .github/workflows/SETUP_GITHUB_ACTIONS.md ================================================ # GitHub Actions 设置指南 ## 快速开始 ### 1. 选择工作流文件 根据你的需求选择合适的工作流文件: **选项A:基础部署(推荐)** ```bash # 使用基础版本,只部署到目标仓库 cp .github/workflows/deploy.yml .github/workflows/ ``` **选项B:增强部署** ```bash # 使用增强版本,包含GitHub Pages cp .github/workflows/deploy-with-pages.yml .github/workflows/deploy.yml ``` ### 2. 提交并推送 ```bash git add .github/workflows/ git commit -m "Add GitHub Actions for auto deployment" git push origin main ``` ### 3. 验证配置 1. 进入GitHub仓库页面 2. 点击 "Actions" 标签 3. 你应该能看到 "Auto Deploy GitBook" 工作流 4. 点击工作流查看详情 ## 配置说明 ### 触发条件 - **自动触发**:每次push到 `main` 或 `master` 分支 - **手动触发**:通过GitHub界面手动运行 ### 目标仓库 工作流会将构建结果推送到:`https://github.com/hitzhangjie/debugger101.io` ### Docker镜像 使用你的自定义Docker镜像:`hitzhangjie/gitbook-cli:latest` ## 测试部署 ### 方法1:推送测试 ```bash # 修改任意文件 echo "# Test" >> README.md git add README.md git commit -m "Test GitHub Actions deployment" git push origin main ``` ### 方法2:手动触发 1. 进入GitHub仓库的Actions页面 2. 选择 "Auto Deploy GitBook" 工作流 3. 点击 "Run workflow" 4. 选择分支并运行 ## 监控部署 ### 查看日志 1. 进入Actions页面 2. 点击最新的工作流运行 3. 查看每个步骤的详细日志 ### 成功标志 - ✅ 所有步骤显示绿色 - 📤 目标仓库收到新的提交 - 🌐 GitHub Pages更新(如果使用增强版本) ## 故障排除 ### 常见问题 **1. 权限错误** ``` Error: fatal: Authentication failed ``` **解决方案**:确保目标仓库存在且有推送权限 **2. Docker镜像错误** ``` Error: Unable to find image 'hitzhangjie/gitbook-cli:latest' ``` **解决方案**:确保Docker镜像已发布到Docker Hub **3. GitBook构建失败** ``` Error: gitbook: command not found ``` **解决方案**:检查 `book/` 目录结构和 `book.json` 配置 ### 获取帮助 如果遇到问题: 1. 查看GitHub Actions的详细日志 2. 检查目标仓库的权限设置 3. 验证Docker镜像是否可用 4. 确认GitBook配置正确 ## 高级配置 ### 自定义触发条件 编辑 `.github/workflows/deploy.yml` 中的 `on` 部分: ```yaml on: push: branches: [ main, master ] paths: [ 'book/**', '*.md' ] # 只监听特定文件变化 workflow_dispatch: ``` ### 环境变量 如果需要使用私有仓库或自定义配置,可以添加环境变量: ```yaml env: TARGET_REPO: https://github.com/hitzhangjie/debugger101.io DOCKER_IMAGE: hitzhangjie/gitbook-cli:latest ``` ### 缓存优化 为了加快构建速度,可以添加缓存: ```yaml - name: Cache node modules uses: actions/cache@v3 with: path: ~/.npm key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} ``` ================================================ FILE: .github/workflows/deploy.yml ================================================ name: Auto Deploy GitBook on: push: branches: [ main, master ] workflow_dispatch: # 允许手动触发 jobs: deploy: runs-on: ubuntu-latest steps: - name: Checkout source code uses: actions/checkout@v4 - name: Build and Deploy GitBook run: | # 创建临时目录 tmpdir=$(mktemp -d) builddir=$(mktemp -d) # 清理函数 cleanup() { rm -rf "$tmpdir" 2>/dev/null || true rm -rf "$builddir" 2>/dev/null || true } trap cleanup EXIT echo "🚀 Starting GitBook build and deployment..." # 克隆目标仓库(使用带token的HTTPS URL) echo "📥 Cloning target repository..." git clone --depth 1 https://${{ secrets.DEPLOY_TOKEN }}@github.com/hitzhangjie/debugger101.io $tmpdir # 使用Docker构建GitBook echo "🔨 Building GitBook with Docker..." docker run --name gitbook --rm \ -v ${PWD}:/root/gitbook \ -v $builddir:$builddir \ hitzhangjie/gitbook-cli:latest \ bash -c "cd book && gitbook install && cd - && gitbook build book $builddir" # 复制构建结果到目标目录 echo "📋 Copying build results..." cp -rf $builddir/* $tmpdir/ # 提交更改 echo "💾 Committing changes..." cd $tmpdir git config --local user.name "hitzhangjie" git config --local user.email "hit.zhangjie@gmail.com" git add . git commit -m "Auto-deploy: $(date)" # 使用 URL 重写方式推送,避免写入凭证文件 git remote set-url origin https://${{ secrets.DEPLOY_TOKEN }}@github.com/hitzhangjie/debugger101.io git push -f -u origin master cd - echo "✅ Deployment completed successfully!" ================================================ FILE: .gitignore ================================================ **/.DS_Store **/_book/ **/node_modules/ debugger101.io ================================================ FILE: .vscode/bookmarks.json ================================================ {} ================================================ FILE: .vscode/settings.json ================================================ { "makefile.configureOnOpen": false, "explorer.sortOrder": "mixed", "files.exclude": { "**/book.en": true }, "explorer.sortOrderReverse": false, "editor.detectIndentation": false, "markdown.validate.enabled": true } ================================================ FILE: Makefile ================================================ chineseWordsCnt := $(shell find book -iname "*.md" -print0 | grep -z -v _book | grep -z -v node_modules | wc -m --files0-from - | tail -n 1 | cut -f1) #englishWordsCnt := $(shell find book.en -iname "*.md" -print0 | grep -z -v zh_CN | grep -z -v _book | grep -z -v node_modules | wc -m --files0-from - | tail -n 1 | cut -f1) deploy := https://github.com/hitzhangjie/debugger101.io tmpdir := /tmp/debugger101.io book := book .PHONY: english chinese stat clean deploy PWD := $(shell pwd -P) # english: # rm -rf book.en/_book # #gitbook install book.en # #gitbook serve book.en # docker run --name gitbook --rm -v ${PWD}/book.en:/root/gitbook hitzhangjie/gitbook-cli:latest gitbook install . # docker run --name gitbook --rm -v ${PWD}/book.en:/root/gitbook -p 4000:4000 -p 35729:35729 hitzhangjie/gitbook-cli:latest gitbook serve . chinese: rm -rf book/_book # #gitbook install book # #gitbook serve book docker run --name gitbook --rm -v ${PWD}/book:/root/gitbook hitzhangjie/gitbook-cli:latest gitbook install . docker run --name gitbook --rm -v ${PWD}/book:/root/gitbook -p 4000:4000 -p 35729:35729 hitzhangjie/gitbook-cli:latest gitbook serve . # pdfchinese: # @echo "Warn: must do it mannually so far for lack of proper docker image," # @echo "- install 'calibre' first (see https://calibre-ebook.com/download)," # @echo "- make sure 'ebook-convert' could be found in envvar 'PATH'," # @echo " take macOS for example:" # @echo " run 'sudo ln -s /Applications/calibre.app/Contents/MacOS/ebook-convert /usr/bin'." # @echo "- run 'gitbook pdf '" # @echo "" stat: @echo "Chinese version, words: ${chineseWordsCnt}" # @echo "English version, words: ${englishWordsCnt}" clean: rm -rf book/_book # #rm -rf book.en/_book # #rm -rf ./node_modules deploy: ./deploy.sh ================================================ FILE: README.md ================================================ # The Art of Debugging: Go Debugger Internals Ever wondered how to develop a Go debugger? Curious about how debuggers work under the hood? This book provides comprehensive insights into these topics. Read the Chinese version online at: https://www.hitzhangjie.pro/debugger101.io/ > English version available at: https://www.hitzhangjie.pro/debugger101-en.io/ > And the repo: https://github.com/hitzhangjie/golang-debugger-book-en .

## Introduction This project delves into the development of a Go debugger, exploring various aspects including: - Operating System support mechanisms - Coordination between compiler, linker, and debugger - Debugging information standards - How to develop an instruction level debugger - How to develop an symbolic level debugger - How does the mordern debugger architect looks like - How do to debug in modern software development - And much more Special thanks to [delve](https://github.com/go-delve/delve) and its author [derek parker](https://twitter.com/derkthedaring?lang=en), maintainer [aarzilli](https://github.com/aarzilli), along with all contributors. Their work has been instrumental in my learning journey, and I'm excited to share this knowledge with developers interested in debugging. Developing a symbolic debugger requires a deep understanding of: - Operating Systems (e.g., Linux ptrace and OS scheduler) - CPU semantics, instructions (e.g., instruction patching), hardware breakpoints register, eflags - Compilers, linkers, and loaders, and the debugger? How do they work together to help debugging - Executable file formats and debugging information storage - The description of different languages features, data, types on different OS, Archs - Programming language-specific features (e.g., goroutines) This project serves as an excellent opportunity to enhance your understanding of computer systems and their underlying technologies. ## Sample Code The project includes a companion repository "**[golang-debugger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons)**" containing sample code that corresponds to each chapter. The "[**0-godbg**](https://github.com/hitzhangjie/godbg)" directory provides a complete implementation of a insctruction-level debugger for Go."[**tinydbg**](https://github.com/hitzhangjie/tinydbg/tree/tinydbg_minimal)" repository is a [go-delve/delve](https://github.com/go-delve/delve) fork and simplified version for **Linux/Amd64** to help you quickly understand the core concepts and code. While established debuggers like GDB and Delve exist for Go, developing a debugger from scratch serves as an excellent learning exercise. It not only helps understand how the debugger works, but also helps integrate knowledge across various domains: - Go language internals (type system, goroutine scheduling) - Go commandline utities development, especially uses spf13/cobra - System level programming, understand how build toolchain works, how kernel works, how CPU works - Go ebpf tracing utilities programming - Operating system kernel concepts (virtual memory, task scheduling, system calls, instruction patching) - etc. > Perhaps more than understanding how debuggers work, deepening my knowledge of Computer Systems was the fundamental motivation behind writing this book. And, I wish this book could help more readers, too. ## Reading Locally The book follows GitBook's structure, but since gitbook-cli is deprecated, we offer two methods to read the book locally: ### Using Docker (Recommended) ```bash # For English version rm book/_book docker run --name gitbook --rm -v ${PWD}/book:/root/gitbook hitzhangjie/gitbook-cli:latest gitbook install . docker run --name gitbook --rm -v ${PWD}/book:/root/gitbook -p 4000:4000 -p 35729:35729 hitzhangjie/gitbook-cli:latest gitbook serve . ``` For convenience, these commands are available in the Makefile - simply run `make english` to start the server. ### Using Legacy gitbook-cli 1. Clone the repository: ```bash git clone https://github.com/hitzhangjie/golang-debugger-book-en ``` 2. Serve the book: ```bash cd book gitbook install && gitbook serve ``` > Note: Installing gitbook-cli directly may encounter compatibility issues with recent Node.js and graceful-fs versions. To avoid these issues, we recommend using our Docker image `hitzhangjie/gitbook-cli:latest` instead of npm or homebrew installation. ## Contact For any questions or feedback, please email me at **hit.zhangjie@gmail.com**. I'll respond as soon as possible. ### License Creative Commons License
This work is licensed under a Creative Commons Attribution-NoDerivatives 4.0 International License . ================================================ FILE: book/10-extras/1-development-of-debugger.md ================================================ ## 软件调试器的发展历程 ### 1. 调试器诞生的问题背景 软件开发从诞生之初便伴随着错误和缺陷。早期的程序员在面对程序错误时,往往依靠最原始的方法:通过打印语句输出变量值,或在关键节点插入输出语句来观察程序的执行流程。这种方法不仅效率低下,还常常无法准确定位复杂问题。 20世纪40年代末至50年代初,当第一批电子计算机开始运行时,程序员面临着极大的挑战: * 编写的程序直接在硬件层面运行,与现代高级语言相比抽象程度极低 * 计算资源极为有限,每一次程序运行都是宝贵的计算时间 * 没有操作系统的辅助,程序错误常常导致整个系统崩溃 * 调试工具几乎不存在,程序员需要通过记忆和纸笔记录程序状态 在这样的背景下,一个能够控制程序执行流程、检查内存和寄存器状态、动态修改变量值的工具变得至关重要。这就是调试器出现的历史必然性。 ### 2. 调试器的技术演进 #### 早期的硬件调试手段(1940s-1950s) 最早的"调试"并非软件概念,而是源于硬件故障修复。传说中的"bug"一词起源于1947年,当时计算机先驱Grace Hopper在Harvard Mark II计算机中发现了一只导致系统故障的飞蛾。工程师们实际使用的调试手段包括: * **控制面板指示灯** :通过观察指示灯的状态来判断程序执行情况 * **打孔纸带** :在纸带上标记执行流程,用于事后分析 * **示波器** :观察信号波形来判断程序行为 #### 早期软件调试器(1960s-1970s) 随着编程语言的发展,真正意义上的软件调试器开始出现: * **DDT (Dynamic Debugging Technique/Tool)** : 1961年在PDP系列计算机上开发的早期调试工具,允许程序员检查和修改内存 * **断点设置** :首次允许程序员在程序特定位置停止执行,检查内存状态 * **IBM的TSS/360调试器** :引入了更多交互式调试功能 这一时期的重大突破是从"事后分析"转向"交互式调试",程序员首次可以在程序运行过程中观察和控制程序行为。 #### 符号级调试器(1970s-1980s) 这一阶段的主要突破在于调试器开始理解源代码和符号,不再局限于机器码层面: * **Source-level debugging** :调试器可以显示源代码而非汇编代码 * **UNIX's sdb和dbx** :引入了更强大的符号级调试功能 * **Symbolic debugger** :可以使用变量名而非内存地址 这些进步极大地提高了调试效率,使程序员可以在熟悉的源代码环境中进行调试。 #### 图形界面调试器(1980s-1990s) 个人计算机时代带来了图形界面的普及,调试器也随之进化: * **Borland的Turbo Debugger** :提供了友好的蓝色界面,成为一代经典 * **Microsoft的Visual Studio Debugger** :集成在IDE中,提供了可视化的调试体验 * **GDB与DDD** :GDB作为命令行调试器的标准,DDD为其提供图形前端 图形界面极大地降低了调试的门槛,使更多程序员能够有效利用调试工具。 #### 分布式和远程调试(1990s-2000s) 随着网络应用的兴起,调试工具开始适应分布式环境: * **远程调试协议** :允许调试器连接到远程运行的程序 * **JPDA (Java Platform Debugger Architecture)** :为Java引入标准化的调试架构 * **Web开发者工具** :浏览器中的JavaScript调试器的出现 #### 现代调试技术(2000s-2010s) * **时间旅行调试(Time-Travel Debugging)** :允许开发者"倒回"程序执行过程 * **硬件辅助调试** :现代处理器提供硬件断点和性能计数器 * **自动化调试** :结合静态分析和动态执行信息自动定位错误 ### 3. 分布式系统与云原生时代的调试挑战 #### 微服务架构下的调试难点 现代应用从单体架构转向微服务,带来了全新的调试挑战: * **服务边界问题** :错误可能发生在服务之间的交互中,而非单一服务内部 * **请求跟踪困难** :一个用户请求可能穿越数十个微服务,难以跟踪完整路径 * **环境一致性** :开发、测试和生产环境差异导致"在我这里能运行"的问题 * **异步通信** :基于消息队列的通信使得调试序列难以重现 #### 云原生环境的挑战 * **容器化应用** :容器的短暂性和不可变性使传统调试模式难以应用 * **Kubernetes复杂性** :Pod的调度和生命周期管理增加了调试的复杂度 * **多云部署** :跨云服务提供商的应用调试需要统一的工具和方法 #### 解决方案与发展方向 现代分布式调试正在向以下方向发展: 1. **分布式追踪系统** : * OpenTelemetry统一标准 * Jaeger、Zipkin等开源工具的普及 * 端到端请求可视化 1. **可观察性三支柱** : * 日志(Logs):结构化日志和集中式日志分析 * 指标(Metrics):实时系统性能监控 * 追踪(Traces):分布式请求路径跟踪 1. **服务网格** : * Istio、Linkerd等提供的流量管理和可观察性 * 边车模式(Sidecar)简化了服务间通信的监控 1. **混沌工程** : * 通过故意引入故障来提前发现系统弱点 * Netflix的Chaos Monkey等工具的应用 ### 4. AI时代调试器的发展方向 人工智能正在深刻改变软件开发的各个环节,调试领域也不例外: #### 智能根因分析 * 利用机器学习模型分析历史故障数据,预测当前错误的可能原因 * 自动关联分布式系统中的异常事件,找出因果关系 * 智能优先级排序,识别最可能导致当前症状的错误 #### 自然语言交互式调试 * 开发者可以用自然语言描述问题:"为什么用户A无法登录?" * AI助手可以检索相关日志、追踪信息,并给出人类可理解的解释 * 大型语言模型(LLM)辅助生成调试策略和修复建议 #### 预测性调试 * 基于代码变更自动识别潜在风险区域 * 在问题发生前预警可能的性能瓶颈或资源耗尽 * 智能测试生成,针对高风险区域自动构建测试用例 #### 自动化修复 * AI系统提出可能的补丁并验证其正确性 * 对于常见模式的错误,自动应用已知修复方案 * 持续学习系统,从每次修复中改进修复策略 #### 程序合成与调试结合 * 使用程序合成技术自动生成符合规范的代码替代有缺陷的部分 * 通过反向工程理解程序员意图,提供更符合原始设计的修复 ### 5. 软件Bug的教训 #### 航天领域的教训 ##### 火星气候轨道器失败(1999年) 美国宇航局(NASA)的火星气候轨道器(Mars Climate Orbiter)价值1.25亿美元,在接近火星进入轨道时失去了联系。经调查发现,地面控制软件使用英制单位(磅-力秒),而飞行器的软件期望使用公制单位(牛顿秒)。这个单位换算问题导致轨道器进入大气层的角度错误,最终坠毁。 教训:单元测试和集成测试的重要性,以及明确定义和验证系统接口的必要性。 ##### 阿丽亚娜5号爆炸(1996年) 欧洲航天局的阿丽亚娜5号火箭在首次发射40秒后爆炸,损失约5亿美元。故障原因是软件试图将64位浮点数转换为16位有符号整数,导致溢出。令人讽刺的是,涉及错误的代码实际上对阿丽亚娜5号来说是多余的,它是从阿丽亚娜4号火箭复制过来的。 教训:软件重用时必须验证新环境下的适用性,以及硬件限制下的软件边界条件测试的重要性。 #### 金融领域的教训 ##### 奈特资本的破产(2012年) 华尔街交易公司Knight Capital因软件部署错误,在45分钟内损失了4.6亿美元,最终导致公司破产。一名工程师忘记将新代码复制到8台服务器中的一台,导致旧系统和新系统混合运行,触发了数百万个错误的交易订单。 教训:自动化部署流程的重要性,以及对关键系统进行全面验证的必要性。 #### 医疗设备的教训 ##### Therac-25放射治疗事故(1985-1987年) Therac-25是一款用于癌症放射治疗的机器,由于软件错误导致至少6名患者接受了过量辐射,其中3人死亡。问题出在竞态条件和操作员界面设计缺陷上,使得致命的高能射线在不应该被激活的情况下被触发。 教训:关键安全系统中的代码审查和严格测试的重要性,以及用户界面设计如何影响系统安全。 #### 通信行业的教训 ##### AT&T网络崩溃(1990年) 1990年1月15日,AT&T的长途电话网络崩溃9小时,影响了美国约7,000万电话用户。故障原因是一个软件更新中的小错误:一条switch语句中的break语句被错误地放置,导致在特定条件下系统重启,进而触发连锁反应。 教训:关键基础设施中的变更管理的重要性,以及代码审查流程的严格性。 ### 6. 总结 调试技术的发展历程反映了软件工程本身的进化。从最初的硬件调试到现代AI辅助调试,每一次技术变革都在解决特定时代的挑战: 1. **早期阶段** 解决了程序可视化和控制的基本问题 2. **符号调试器** 使调试更加人性化,贴近源代码 3. **图形界面** 降低了调试工具的使用门槛 4. **分布式调试** 适应了网络应用的复杂需求 5. **云原生调试** 应对现代微服务架构的挑战 6. **AI辅助调试** 正在解决规模和复杂度不断增长的软件系统问题 软件调试不仅是一项技术活动,更是软件质量的最后防线。正如历史上许多灾难性的软件故障所示,一个看似微小的bug可能导致巨大的经济损失,甚至危及生命。在AI时代,调试工具将继续演化,但其核心目标始终不变:帮助开发者理解程序行为,及时发现并修复错误,确保软件系统的可靠性和安全性。 随着软件继续渗透到人类生活的各个方面,高效的调试工具和方法将变得比以往任何时候都更加重要。未来的调试技术将更加智能、更加自动化,但也将需要开发者具备更全面的系统思维和更深入的技术理解,才能充分发挥这些工具的潜力。 ================================================ FILE: book/10-extras/2-development-of-logging.md ================================================ ## 软件日志系统的发展历程 ### 1. 打日志诞生的问题背景 软件日志系统的起源可以追溯到计算机系统诞生的早期。在计算机技术发展初期,程序员们面临着一个共同的困境:如何有效地监控和调试程序执行过程。当时的程序调试主要依靠打印语句,程序员手动在代码中插入打印语句,输出变量值和执行流程信息,以此来追踪程序执行路径和定位错误。 这种原始方法存在诸多问题: * **代码侵入性强** :调试代码与业务逻辑混杂在一起 * **难以管理** :调试完成后需要手动删除或注释这些打印语句 * **缺乏标准化** :不同开发者使用不同的打印格式和方法 * **难以在生产环境中应用** :无法动态控制日志输出级别和目标 随着软件系统规模的不断扩大和复杂性的增加,这些问题变得越来越突出。开发者们迫切需要一种系统化、标准化的方法来记录程序运行信息,以便于问题定位和系统监控。这种需求催生了专门的日志系统。 ### 2. 日志系统的发展历程 #### 2.1 早期系统日志 Unix系统的syslog是最早的系统化日志解决方案之一,诞生于1980年代。它提供了一个集中的日志记录机制,允许应用程序将日志信息发送到系统日志守护进程,由该进程统一处理和存储日志信息。syslog引入了日志级别和设施的概念,实现了日志的分类和筛选功能。 #### 2.2 应用级日志框架的出现 1990年代末至2000年代初,随着面向对象编程的普及,专门的应用级日志框架开始出现: * **Log4j (1999)** : Apache Log4j是Java平台上最早的专业日志框架之一,由Ceki Gülcü开发。它引入了日志级别、日志分类和可配置的日志输出目标等概念,奠定了现代日志框架的基础。 * **SLF4J (2005)** : 简单日志门面(Simple Logging Facade for Java)提供了一个抽象层,使应用程序可以使用各种日志实现,而不需要更改代码。 * **各语言的日志框架** : Python的logging模块、.NET的log4net、C++的log4cpp等,不同编程语言平台都开发了自己的日志框架。 #### 2.3 日志系统的重大教训与突破 ##### Log4Shell漏洞事件(2021) 2021年12月,Log4j中的一个严重安全漏洞(CVE-2021-44228)震惊了整个技术界。这个被称为"Log4Shell"的漏洞允许攻击者通过向使用Log4j的应用程序发送特制的消息,执行任意代码。这一事件凸显了日志系统安全性的重要性,并导致了对日志框架安全设计的重新审视。 ##### 结构化日志的兴起 随着数据处理技术的发展,传统的纯文本日志逐渐显露出局限性。结构化日志(如JSON格式)的出现使日志信息更易于机器处理和分析,成为现代日志系统的一个重要突破。 ### 3. 分布式系统时代的日志挑战与解决方案 #### 3.1 分布式系统的日志挑战 随着互联网规模的扩大,单体应用逐渐演变为分布式系统和微服务架构,日志系统面临的挑战也随之变化: * **日志收集与聚合** :分散在多个节点的日志需要集中收集和处理 * **分布式跟踪** :单个请求可能跨越多个服务,需要跟踪完整的请求路径 * **海量数据处理** :日志数据量呈爆炸式增长,对存储和处理能力提出挑战 * **实时分析需求** :需要从海量日志中快速提取有价值的信息 #### 3.2 ELK/EFK Stack 为应对这些挑战,一系列专门的日志收集、处理和分析工具应运而生,其中最具代表性的是ELK堆栈: * **Elasticsearch** :分布式搜索引擎,提供高效的日志存储和查询能力 * **Logstash** :日志收集和处理管道 * **Kibana** :数据可视化和分析平台 * **Beats(后加入)** :轻量级日志收集代理 #### 3.3 分布式追踪系统 为解决分布式系统中的请求跟踪问题,专门的分布式追踪系统被开发出来: * **Google Dapper (2010)** :Google发表的分布式追踪系统论文,奠定了现代分布式追踪的理论基础 * **Zipkin, Jaeger** :受Dapper启发的开源实现 * **OpenTelemetry** :统一的可观测性框架,整合了分布式追踪、度量和日志 ### 4. 云原生时代的日志系统 #### 4.1 云原生环境的特点与挑战 云原生时代的特点包括容器化、动态编排、短暂性实例等,这给日志系统带来了新的挑战: * **短暂性实例的日志保存** :容器可能随时启动或销毁,其本地日志也会随之消失 * **动态扩缩容** :日志收集系统需要适应动态变化的服务实例数量 * **多租户环境** :需要隔离不同租户的日志数据 * **自动化与可观测性** :需要与自动化运维系统集成,提供全面的可观测性 #### 4.2 云原生日志解决方案 为应对这些挑战,云原生日志解决方案应运而生: * **Sidecar模式** :在每个应用容器旁边部署专门的日志收集容器 * **Fluentd/Fluent Bit** :轻量级、云原生友好的日志收集器 * **Loki** :Grafana开发的轻量级日志聚合系统,专为Kubernetes设计 * **Vector** :高性能、可扩展的日志处理系统 #### 4.3 可观测性三支柱的融合 在云原生环境中,日志、指标和追踪这三大可观测性支柱开始融合,形成统一的可观测性解决方案: * **统一的数据模型** :OpenTelemetry提供了统一的数据收集和处理标准 * **关联分析** :将日志、指标和追踪数据关联起来,提供全面的系统视图 * **AIOps的兴起** :利用人工智能技术对可观测性数据进行智能分析 ### 5. 人工智能时代的日志系统展望 随着人工智能技术的快速发展,日志系统正在经历新一轮的变革: #### 5.1 AI增强的日志分析 * **异常检测** :使用机器学习算法自动识别日志中的异常模式 * **根因分析** :AI可以分析各种日志和指标数据,自动推断问题的根本原因 * **预测性维护** :基于历史日志数据预测潜在的系统故障 * **自然语言处理** :允许工程师使用自然语言查询日志数据 #### 5.2 自适应日志系统 * **智能采样** :根据上下文重要性动态调整日志详细程度 * **自优化存储** :智能决定哪些日志需要长期保存,哪些可以压缩或归档 * **上下文感知** :根据系统状态自动调整日志级别和内容 #### 5.3 大型语言模型(LLM)与日志分析 * **日志总结与理解** :LLM可以将复杂的日志数据总结为人类可理解的叙述 * **智能问答** :开发人员可以直接向系统询问有关日志的问题 * **代码与日志关联** :将日志与源代码关联,自动提供修复建议 #### 5.4 隐私与合规的智能处理 * **自动敏感数据识别** :AI可以识别并处理日志中的敏感个人信息 * **智能合规监控** :确保日志处理符合GDPR、CCPA等隐私法规 ### 6. 总结与展望 软件日志系统从最初的简单打印语句,发展到今天的复杂分布式可观测性平台,经历了巨大的变革。每个阶段的变化都是为了应对当时软件架构和规模带来的新挑战。 回顾这一发展历程,我们可以看到几个关键趋势: 1. **从简单到复杂** :日志系统已从简单的文本记录发展为完整的可观测性解决方案 2. **从孤立到集成** :日志系统与监控、追踪等其他系统日益融合 3. **从被动到主动** :从被动记录信息到主动分析和预警 4. **从人工到智能** :从人工分析日志到AI辅助和自动化分析 未来,随着人工智能技术的进一步发展和软件系统复杂度的不断提高,日志系统将继续演进。我们可以期待看到更加智能、自适应的日志系统,它们不仅能记录发生了什么,还能理解为什么发生,甚至预测将要发生什么,成为软件系统可靠性和安全性的重要保障。 在这个过程中,日志系统将不再只是一个技术工具,而是成为连接开发、运维和业务的桥梁,为整个软件生命周期提供关键的数据支持和决策依据。 ================================================ FILE: book/10-extras/3-development-of-metrics.md ================================================ ## 软件监控指标系统的发展历程 ### 1. 监控打点诞生的问题背景 在软件系统的早期发展阶段,工程师们面临着一个普遍的困境:当系统出现故障时,往往难以快速定位问题所在。这种情况就像是在黑暗中摸索,没有有效的工具来指引方向。 最初的故障排查方法相当原始:开发人员通过查看日志文件,或者在代码中插入打印语句来观察系统行为。然而,随着系统规模的扩大,这种方法变得越来越低效。特别是当面对生产环境中的问题时,这些方法往往会造成滞后的问题诊断,导致严重的服务中断和业务损失。 监控打点(instrumentation)正是在这样的背景下应运而生。它的核心理念是在系统的关键位置埋入"探针",实时收集系统运行状态的数据,从而使工程师能够: * 实时了解系统的健康状况 * 在问题发生时快速定位根源 * 通过历史数据分析系统行为模式 * 预测潜在的风险并提前采取措施 监控指标系统的诞生,标志着软件工程从被动响应向主动预防的重要转变,为现代高可用系统奠定了基础。 ### 2. 指标系统从诞生到现在的技术演进 #### 早期:简单计数器和日志分析 最初的监控系统非常简单,主要依赖于: * 基本计数器:记录请求次数、错误次数等简单数值 * 日志分析:通过分析日志文件来推断系统状态 * 系统自带工具:如Unix/Linux的 `top`、`vmstat`等命令 这个阶段的监控主要是针对单机环境,缺乏统一的标准和集中化的视图。 #### 集中式监控系统的兴起 随着系统规模扩大,集中式监控系统开始出现: * **Nagios** (1999年):作为最早的开源监控系统之一,Nagios提供了基于主机和服务的监控框架。 * **Ganglia** (2000年代初):专为高性能计算集群设计,引入了时间序列数据的概念。 * **Graphite** (2006年):提供了存储时间序列数据和图形化展示的能力,使得指标可视化变得更加直观。 * **Munin** 、**Cacti**等:进一步完善了数据收集和可视化能力。 这个阶段的突破在于实现了多主机的集中监控,但系统架构仍然相对简单,主要关注于基础设施层面。 #### 现代指标系统的形成 2010年后,随着云计算和大规模分布式系统的兴起,监控指标系统迎来了革命性的变化: * **Prometheus** (2012年):引入了多维度数据模型和强大的查询语言PromQL,成为云原生监控的标准之一。 * **OpenTSDB** (2010年):基于HBase的可扩展时间序列数据库,能够处理大规模指标数据。 * **InfluxDB** (2013年):专为时间序列数据优化的数据库,提供高效的写入和查询性能。 * **Grafana** (2014年):强大的可视化平台,整合了多种数据源,成为监控面板的事实标准。 这个阶段的关键特点是: * 多维度标签(labels/tags)的广泛采用,使得指标数据更具表达力 * 分布式架构设计,支持大规模部署 * 更丰富的指标类型:计数器(Counter)、仪表盘(Gauge)、直方图(Histogram)、汇总(Summary)等 * 强大的查询语言和告警功能 #### 标准化与生态系统 近年来,监控指标系统在标准化和生态系统构建方面取得了重要进展: * **OpenMetrics** :源于Prometheus的暴露格式,成为CNCF的孵化项目,旨在建立统一的指标暴露标准。 * **OpenTelemetry** :整合了OpenTracing和OpenCensus,提供了统一的可观测性框架,覆盖指标、追踪和日志。 * **CNCF Observability** :将监控作为云原生生态系统的核心组成部分,推动了行业标准的形成。 ### 3. 不同时代的挑战与应对 #### 分布式系统时代的挑战 分布式系统带来了全新的监控挑战: * **系统规模激增** :节点数量从数十增长到数千甚至数万 * **依赖关系复杂化** :服务之间的调用关系形成复杂网络 * **故障模式多样化** :出现了更多难以预测的故障类型 **惨痛教训** :2010年代初,许多大型互联网公司在向分布式架构迁移过程中,因为监控系统未能跟上架构变化而导致严重事故。如亚马逊在2011年的一次著名宕机事件,持续了近四天,造成了数百万美元的损失,部分原因是现有监控系统无法有效追踪分布式存储系统中的级联故障。 应对措施: * 采用可扩展的监控架构,如Prometheus的联邦模式 * 引入服务发现机制,自动适应动态变化的环境 * 开发专门针对分布式系统的指标,如延迟分布、错误预算等 #### 微服务时代的挑战 微服务架构进一步分解了系统边界,带来新的挑战: * **服务数量爆炸式增长** :从数十个服务增加到数百甚至数千个 * **更频繁的部署与变更** :CI/CD流水线使得变更频率大幅提高 * **服务边界模糊** :用户体验往往跨越多个服务 **惨痛教训** :Netflix在早期微服务转型中,曾因为监控系统不到位而导致多次严重故障。2012年,他们的一次重大服务中断就是因为无法及时发现微服务之间的异常调用模式,导致故障快速蔓延。这直接促使他们开发了著名的Chaos Monkey和更完善的监控系统。 应对措施: * 服务网格(Service Mesh)技术的应用,提供统一的监控埋点 * RED方法论:请求率(Rate)、错误率(Error)和持续时间(Duration) * 分布式追踪的广泛应用,如Zipkin、Jaeger等 #### 云原生时代的挑战 云原生环境引入了更多动态性和抽象层: * **基础设施抽象化** :物理资源被多层虚拟化 * **短生命周期组件** :容器可能只存在几分钟甚至几秒 * **异构和多云环境** :需要跨不同环境收集一致的指标 **惨痛教训** :2017年,一家大型金融科技公司在迁移到Kubernetes平台后,经历了一次严重的服务降级事件。他们的监控系统无法适应容器的高动态性,导致无法检测到资源争用问题,影响了数百万用户的交易处理。这个事件直接促使他们重新设计了整个可观测性栈。 应对措施: * 采用云原生监控解决方案,如Prometheus + Grafana * 容器化监控代理,实现自动发现和自愈 * SLO(Service Level Objective)和error budget的引入 * eBPF技术的应用,提供内核级的可观测性 ### 4. 人工智能时代的发展方向 随着AI技术的成熟,监控指标系统正在朝着以下方向发展: #### AIOps的崛起 人工智能正在从根本上改变监控范式: * **异常检测** :基于机器学习的算法自动发现异常模式,不再依赖人为设定的阈值 * **根因分析** :AI可以分析复杂的依赖关系,快速定位故障根源 * **预测性维护** :通过历史数据预测潜在故障,实现提前干预 #### 智能告警与噪音减少 告警疲劳是传统监控系统的痛点之一,AI正在改变这一现状: * **智能分组** :自动将相关告警归类,减少重复通知 * **动态阈值** :适应系统的自然变化,减少误报 * **上下文感知** :考虑业务周期、维护窗口等因素 #### 自适应监控 监控系统本身正变得更加智能和自主: * **自动发现** :智能识别需要监控的新服务和端点 * **自调优** :根据系统负载动态调整采样率和精度 * **自恢复** :监控系统具备自我修复能力 #### 大规模指标数据的智能处理 随着监控数据量的爆炸式增长,新的挑战与机遇并存: * **高效存储和查询** :新型时间序列数据库专为AI时代设计 * **智能采样** :使用统计学方法在保持准确性的同时减少数据量 * **自动数据生命周期管理** :智能确定数据的保留策略 #### 深度分析与业务集成 监控不再局限于技术指标,而是更加贴近业务: * **业务指标关联** :将技术指标与业务成果直接关联 * **用户体验监控** :从用户视角衡量服务质量 * **行为分析** :结合用户行为数据,提供更全面的系统视图 ### 5. 总结与展望 监控指标系统的发展历程反映了软件工程本身的演进:从简单到复杂,从静态到动态,从被动响应到主动预防。我们可以看到几个关键趋势: 1. **整合趋势** :指标、日志和追踪正在融合为统一的可观测性平台 2. **智能化趋势** :AI正在重塑监控的每个环节,从数据收集到分析决策 3. **业务导向** :技术监控与业务目标的对齐日益紧密 4. **自治系统** :监控系统本身正变得更加自适应和自我管理 未来的监控指标系统将不再仅仅是一个工具,而是智能运维的核心神经系统,能够自主感知、分析和调整,实现真正的闭环自动化。在这个AI驱动的新时代,监控将从"观察系统"演变为"理解系统",甚至是"预测系统",为软件工程的下一个飞跃奠定基础。 然而,我们也应该记住,技术的进步不应该让我们忽视监控的本质目的:确保系统可靠地为用户提供价值。无论监控技术如何先进,它始终是一种手段,而非目的本身。真正的挑战在于如何将这些技术进步转化为更可靠的系统和更好的用户体验,这也将是监控指标系统未来发展的核心驱动力。 ================================================ FILE: book/10-extras/4-development-of-tracing.md ================================================ ## 软件跟踪系统(Tracing)的发展历程 ### 1. Tracing诞生的问题背景 软件跟踪系统(Tracing)的出现源于开发者面临的一个永恒挑战:如何有效地理解和排查复杂系统中的问题。在早期计算机系统中,调试主要依靠简单的日志记录和调试器,这些工具在单体应用中表现尚可,但随着系统规模的扩大和复杂度的提升,它们逐渐显露出局限性。 在20世纪90年代末到21世纪初,随着互联网的兴起,分布式系统开始普及。开发者们面临着前所未有的挑战: * 系统组件分布在不同的物理机器上 * 请求穿越多个服务和网络边界 * 失败可能发生在任何环节,且难以定位 * 性能问题的根源变得更加难以追踪 传统的调试方法在这种环境下捉襟见肘。当一个请求在分布式系统中失败时,开发人员不得不手动关联各个组件的日志,这是一项耗时且容易出错的工作。这种痛点催生了对更先进跟踪技术的需求,促使了专门的分布式跟踪系统的诞生。 ### 2. Tracing系统的技术演进 #### 早期基础:从日志到分布式跟踪 分布式跟踪的概念最早可以追溯到2000年代初期。这一时期的重要里程碑包括: **Magpie (2003)** :微软研究院开发的系统,能够自动提取分布式系统中事件的因果关系,被认为是分布式跟踪的先驱之一。 **X-Trace (2007)** :UC Berkeley开发的框架,首次引入了跨多个协议和系统边界的端到端跟踪能力。X-Trace的创新之处在于它为每个请求分配了唯一标识符,使得追踪请求路径成为可能。 #### Google Dapper:分布式跟踪的奠基石 2010年,Google发表了题为《Dapper, a Large-Scale Distributed Systems Tracing Infrastructure》的论文,这被广泛认为是现代分布式跟踪系统的奠基之作。Dapper引入了几个关键概念: * **Trace** :表示一个分布式事务或请求的完整路径 * **Span** :表示在单个服务中完成的工作单元 * **SpanId和TraceId** :用于唯一标识和关联分布式系统中的操作 Dapper的设计理念影响深远,它平衡了低开销和高可用性,同时保持了对开发者的透明性。这些特性使其成为后来众多开源跟踪系统的灵感来源。 #### 开源生态的繁荣 在Dapper论文发表后,开源社区开始活跃开发各种分布式跟踪解决方案: **Zipkin (2012)** :由Twitter开源,直接受Dapper启发的分布式跟踪系统,使用简单的库使开发人员能够检测他们的代码。 **Jaeger (2016)** :由Uber开发并开源,兼容OpenTracing API,提供了分布式上下文传播、分布式事务监控等功能。 **OpenTracing (2016)** :一个厂商中立的开放标准,旨在统一分布式跟踪接口。通过OpenTracing,开发者可以使用一致的API,而不必关心底层的具体实现。 **OpenCensus (2018)** :由Google主导的项目,将指标收集和分布式跟踪结合在一个框架中。 #### 标准化:OpenTelemetry的诞生 分布式跟踪生态系统的多样化也带来了碎片化的问题。为解决这一挑战,2019年,OpenTracing和OpenCensus项目合并,形成了 **OpenTelemetry** 。这一里程碑事件标志着分布式观测领域向标准化的重要一步。 OpenTelemetry提供: * 与厂商无关的APIs和SDKs * 用于收集和处理遥测数据的规范 * 整合了分布式跟踪、指标和日志 * 广泛的语言和平台支持 到2021年,OpenTelemetry已经成为云原生计算基金会(CNCF)的第二大活跃项目,仅次于Kubernetes,显示了业界对统一可观测性标准的强烈需求。 ### 3. 不同时代的挑战与应对 #### 分布式系统时代的挑战 在早期分布式系统中,tracing面临的主要挑战包括: * **性能开销** :早期跟踪系统对应用性能影响较大 * **兼容性** :不同语言和框架的集成难度高 * **采样策略** :如何在数据量和精确度之间取得平衡 **惨痛教训** :2012年,一家大型电子商务平台在黑色星期五部署了新版跟踪系统,但由于跟踪代理的高CPU占用,导致整个交易系统响应缓慢,损失数百万美元的销售额。这一事件凸显了在设计跟踪系统时必须考虑性能影响。 #### 微服务时代的挑战 随着微服务架构的流行,新的挑战出现: * **服务激增** :需要追踪穿越数十甚至数百个微服务的请求 * **异构环境** :不同技术栈的服务需要统一的跟踪方案 * **上下文传播** :在异步通信和事件驱动架构中保持跟踪上下文 **应对措施** : * 轻量级跟踪协议的发展 * 自动化检测技术的提升 * 智能采样算法的应用 **惨痛教训** :2018年,一家金融科技公司的支付系统因为跟踪上下文传播失败,导致无法确定哪些交易成功完成、哪些失败,最终不得不进行长达36小时的系统重建,造成严重的用户信任危机。 #### 云原生时代的挑战 云原生环境为Tracing带来了更复杂的场景: * **动态基础设施** :容器和Kubernetes环境中服务实例频繁创建和销毁 * **服务网格** :如Istio等技术引入了新的通信层 * **无服务器架构** :函数计算(FaaS)模型下的跟踪复杂性 * **可观测性融合** :需要将跟踪与日志、指标等其他信号整合 **应对策略** : * 服务网格中的sidecar自动注入跟踪信息 * 云原生跟踪收集器的发展 * 自动关联各种可观测性数据 ### 4. 人工智能时代的Tracing发展方向 随着人工智能技术的发展,Tracing系统正在迎来新的变革: #### AI驱动的异常检测与根因分析 现代系统生成大量跟踪数据,人工分析变得几乎不可能。AI可以帮助: * 自动识别异常的请求路径和模式 * 预测潜在的系统瓶颈和故障点 * 通过机器学习模型关联根本原因 例如,Facebook的Narya系统利用机器学习预测网络中的潜在故障并自动修复,大幅减少了系统中断。 #### 大规模语言模型的应用 LLM正在改变开发者与跟踪数据的交互方式: * 自然语言查询跟踪数据("上周星期四的支付失败是什么原因?") * 自动生成故障排查建议 * 将复杂的跟踪数据转化为人类可理解的叙述 #### 智能采样与压缩 AI可以优化跟踪数据的采集策略: * 针对特定请求路径的自适应采样率 * 在保留关键信息的同时压缩跟踪数据 * 基于过往模式预测性地调整采样行为 #### 自治修复能力 未来的跟踪系统可能不仅仅是观测工具,还能实现自动修复: * 实时检测到异常后自动调整系统配置 * 基于历史跟踪数据训练的AI模型提供优化建议 * 在某些场景下实现全自动故障修复 #### 分布式AI系统的跟踪挑战 随着AI系统本身变得更加分布式和复杂,跟踪这些系统也带来了新的挑战: * 追踪大规模分布式训练和推理过程 * 理解和可视化复杂的神经网络决策路径 * 监控和调试AI模型性能波动的根本原因 ### 5. 总结与展望 从最初解决分布式系统调试难题的工具,到如今融合AI技术的智能化系统,软件跟踪技术已经走过了长足的发展历程。这一演进过程清晰地反映了软件系统本身从单体到分布式,再到云原生和AI驱动的技术变革。 关键的发展脉络可以总结为: 1. **问题驱动** :每一次技术突破都来源于实际开发运维痛点 2. **标准化** :从各自为战到OpenTelemetry的统一标准 3. **融合化** :从单一的跟踪向全方位可观测性的转变 4. **智能化** :AI技术为跟踪系统注入智能分析能力 未来,随着系统复杂度的持续提升和AI技术的深入应用,跟踪系统将继续演化,可能会呈现以下趋势: * **预测性洞察** :从被动观测转向主动预测 * **无代码跟踪** :降低开发者集成成本 * **上下文感知** :更加智能地理解业务语境 * **隐私保护** :在保证可观测性的同时保护敏感数据 无论技术如何变革,跟踪系统的核心价值始终如一:让开发者能够理解、监控和优化他们创建的系统,确保软件能够可靠高效地运行,为用户提供优质的服务体验。 ================================================ FILE: book/10-extras/5-development-of-bisect.md ================================================ ## Go编译器+运行时DEBUG:基于哈希的二分调试技术 【注】本文翻译自 Russ Cox 于2024-07-18 发表的一篇文章[《Hash-Based Bisect Debugging in Compilers and Runtimes》](https://research.swtch.com/bisect)。为了本章bisect reduce调试技术的内容完整性,特别引用并翻译该文章来介绍bisect reduce的算法逻辑。 ### [背景介绍](https://research.swtch.com/bisect#setting_the_stage) 这样的场景是否似曾相识?你对某个库进行修改以优化性能、清理技术债务或修复bug,结果却收到一份错误报告:某个庞大而复杂的测试用例现在失败了。或者你添加了一个新的编译器优化,结果也大同小异。现在你不得不在一个完全陌生的代码库中进行大量的调试工作。 如果我告诉你,存在一种"魔法棒"可以精确定位那个陌生代码库中的相关代码行或调用栈,你会作何感想?这种工具确实存在。这是一个真实可用的工具,我将向你展示它的神奇之处。这个描述可能听起来有些夸张,但每次我使用这个工具时,它确实给我带来魔法般的感觉。这不是普通的魔法,而是最棒的魔法:即使你完全了解它的工作原理,观看它的运行过程仍然令人赏心悦目。[](https://research.swtch.com/bisect#binary_search_and_bisecting_data) ### [二分搜索与数据二分](https://research.swtch.com/bisect#binary_search_and_bisecting_data) 在介绍新技巧之前,让我们先回顾一些更简单、更基础的技术。每个优秀的魔术师都从掌握基本功开始。在我们的场景中,这个基本功就是二分搜索。大多数二分搜索的演示都专注于在有序列表中查找元素,但实际上它有着更加有趣的应用。这是我很久以前为Go的[`sort.Search`](https://go.dev/pkg/sort/#Search)文档编写的一个例子: ```go func GuessingGame() { var s string fmt.Printf("Pick an integer from 0 to 100.\n") answer := sort.Search(100, func(i int) bool { fmt.Printf("Is your number <= %d? ", i) fmt.Scanf("%s", &s) return s != "" && s[0] == 'y' }) fmt.Printf("Your number is %d.\n", answer) } ``` 如果我们运行这段代码,它会和我们玩一个猜数字游戏: ```bash % go run guess.go Pick an integer from 0 to 100. Is your number <= 50? y Is your number <= 25? n Is your number <= 38? y Is your number <= 32? y Is your number <= 29? n Is your number <= 31? n Your number is 32. % ``` 同样的猜数字游戏原理也可以应用到调试中。Jon Bentley在他1983年9月发表于《ACM通讯》的《编程珠玑》专栏"Aha! Algorithms"中,将二分搜索称为"寻找问题的解决方案"。这是他给出的一个经典例子: > Roy Weil在清理大约一千张包含一张坏卡片的穿孔卡片时应用了[二分搜索]技术。不幸的是,坏卡片无法通过视觉识别;只能通过将卡片的某个子集运行程序并观察严重错误的答案来识别——这个过程需要几分钟。他的前任们试图通过一次运行几张卡片来解决这个问题,并朝着解决方案稳步(但缓慢)前进。Weil是如何在仅十次程序运行中找到罪魁祸首的? 显然,Weil使用了二分搜索的猜数字游戏策略。坏卡片在前500张中吗?是的。前250张中吗?不是。以此类推。这是我能够找到的关于通过二分搜索进行调试的最早发表描述。在这种情况下,它是用于调试数据的。[](https://research.swtch.com/bisect#bisecting_version_history) ### [基于提交历史的二分搜索](https://research.swtch.com/bisect#bisecting_version_history) 我们可以将二分搜索应用到程序的版本历史上,而不仅仅是数据上。每当我们发现旧程序中出现新错误时,我们就会玩猜数字游戏:"这个程序最后一次正常工作是什么时候?" * 50天前它工作正常吗?是的。 * 25天前它工作正常吗?不是。 * 38天前它工作正常吗?是的。 以此类推,直到我们发现程序最后一次正确工作是在32天前,这意味着错误是在31天前引入的。 通过时间进行二分搜索调试是一个非常古老的技巧,被许多人独立发现了很多次。例如,我们可以使用像`cvs checkout -D '31 days ago'`这样的命令或Plan 9的[更音乐化的](https://9fans.github.io/plan9port/man/man1/yesterday.html)`yesterday -n 31`来玩猜数字游戏。对于一些程序员来说,使用二分搜索来调试数据或通过时间调试的技术似乎"[如此基础,以至于没有必要写下来](https://groups.google.com/g/comp.compilers/c/vGh4s3HBQ-s/m/qmrVKmF5AgAJ)"。但记录这些技巧是确保每个人都能掌握的第一步:魔术技巧可以是基础的,但不一定是显而易见的。在软件中,记录技巧也是自动化它和构建优秀工具的第一步。 在1990年代后期,版本历史二分搜索的想法[至少被记录过两次](https://groups.google.com/g/comp.compilers/c/vGh4s3HBQ-s/m/Chvpu7vTAgAJ)。Brian Ness和Viet Ngo在COMPSAC '97(1997年8月)发表了"[通过源代码变更隔离进行回归控制](https://ieeexplore.ieee.org/abstract/document/625082)",描述了他们在Cray Research构建的一个系统,用于交付更频繁的非回归编译器版本。独立地,Larry McVoy在Linux 1.3.73版本(1996年3月)中发布了一个文件"[Documentation/BUG-HUNTING](https://elixir.bootlin.com/linux/1.3.73/source/Documentation/BUG-HUNTING)"。他捕捉到了这个技巧的神奇之处:即使你对被测试的代码没有特别的专业知识,它也能发挥作用: > 如果你对内核黑客一无所知,这就是如何追踪错误的方法。这是一种蛮力方法,但效果很好。 > > 你需要: > > * 一个可重现的错误 - 它必须可预测地发生(抱歉) > * 从工作版本到不工作版本的所有内核tar文件 > > 然后你将: > > * 重新构建一个你认为工作的版本,安装并验证它。 > * 在内核上进行二分搜索,找出哪个版本引入了错误。即,假设1.3.28没有错误,但你知道1.3.69有。选择一个中间的内核并构建它,比如1.3.50。构建和测试;如果它工作,选择.50和.69之间的中点,否则选择.28和.50之间的中点。 > * 你将缩小到引入错误的内核。你可能可以做得比这更好,但这变得棘手。 > > . . . > > 我为向Linus和其他内核黑客描述这种蛮力方法而道歉,这几乎不是内核黑客会做的事情。然而,它确实有效,它让非黑客帮助修复错误。这很酷,因为Linux快照让你可以这样做 - 这是你无法用供应商提供的版本做的事情。 后来,Larry McVoy创建了Bitkeeper,Linux将其用作第一个源代码控制系统。Bitkeeper提供了一种通过提交的有向无环图打印最长直线变更的方法,为二分搜索提供了更细粒度的时间线。当Linus Torvalds创建Git时,他将这个想法进一步发展,推出了[`git rev-list --bisect`](https://github.com/git/git/commit/8b3a1e056f2107deedfdada86046971c9ad7bb87),这启用了相同类型的手动二分搜索。在添加该功能几天后,他在Linux内核邮件列表上[解释了如何使用它](https://groups.google.com/g/fa.linux.kernel/c/N4CqlNCvFCY/m/ItQoFhVZyJgJ): > 嗯..既然你似乎是git用户,也许你可以尝试git的"bisect"功能来帮助缩小确切发生的位置(并帮助测试那个东西;)。 > > 你基本上可以使用git找到一组"已知良好"点和"已知错误"点之间的中点("二分"提交集),只做几次这些应该给我们一个更好的视图,了解事情开始出错的地方。 > > 例如,既然你知道2.6.12-rc3是好的,而2.6.12是坏的,你会这样做 > > git-rev-list --bisect v2.6.12 ^v2.6.12-rc3 > > 其中"v2.6.12 ^v2.6.12-rc3"基本上意味着"v2.6.12中的所有内容但_不是_v2.6.12-rc3中的内容"(这就是^标记的含义),而"--bisect"标志只是要求git-rev-list列出最中间的提交,而不是那些内核版本之间的所有提交。 这个回应引发了一场[关于简化过程的单独讨论](https://groups.google.com/g/fa.linux.kernel/c/cp6abJnEN5U/m/5Z5s14LkzR4J),最终催生了今天广泛使用的[`git bisect`](https://git-scm.com/docs/git-bisect)工具。 这里有一个实际例子。我们尝试更新到Go的更新版本,发现一个测试失败了。我们可以使用`git bisect`来精确定位导致失败的特定提交: ```bash % git bisect start master go1.21.0 Previous HEAD position was 3b8b550a35 doc: document run.. Switched to branch 'master' Your branch is ahead of 'origin/master' by 5 commits. Bisecting: a merge base must be tested [2639a17f146cc7df0778298c6039156d7ca68202] doc: run rel... % git bisect run sh -c ' git clean -df cd src ./make.bash || exit 125 cd $HOME/src/rsc.io/tmp/timertest/retry go list || exit 0 go test -count=5 ' ``` 编写正确的`git bisect`调用需要一些注意,但一旦配置正确,你就可以放手让它自动运行,让`git bisect`施展它的魔法。在这种情况下,我们传递给`git bisect run`的脚本会清理任何过时的文件,然后构建Go工具链(`./make.bash`)。如果那一步失败,它以代码125退出,这是`git bisect`的特殊不确定答案:这个提交有其他问题,我们不能确定我们正在寻找的错误是否存在。否则它切换到失败测试的目录。如果`go list`失败,这在bisect使用太旧的Go版本时会发生,脚本成功退出,表示错误不存在。否则脚本运行`go test`并以该命令的状态退出。`-count=5`在那里是因为这是一个不稳定的失败,不总是发生:运行五次足以确保我们观察到错误(如果它存在的话)。 当我们运行这个命令时,`git bisect`打印大量输出,以及我们测试脚本的输出,以确保我们可以看到进度: ```bash % git bisect run ... ... go: download go1.23 for darwin/arm64: toolchain not available Bisecting: 1360 revisions left to test after this (roughly 10 steps) [752379113b7c3e2170f790ec8b26d590defc71d1] runtime/race: update race syso for PPC64LE ... go: download go1.23 for darwin/arm64: toolchain not available Bisecting: 680 revisions left to test after this (roughly 9 steps) [ff8a2c0ad982ed96aeac42f0c825219752e5d2f6] go/types: generate mono.go from types2 source ... ok rsc.io/tmp/timertest/retry 10.142s Bisecting: 340 revisions left to test after this (roughly 8 steps) [97f1b76b4ba3072ab50d0d248fdce56e73b45baf] runtime: optimize timers.cleanHead ... FAIL rsc.io/tmp/timertest/retry 22.136s Bisecting: 169 revisions left to test after this (roughly 7 steps) [80157f4cff014abb418004c0892f4fe48ee8db2e] io: close PipeReader in test ... ok rsc.io/tmp/timertest/retry 10.145s Bisecting: 84 revisions left to test after this (roughly 6 steps) [8f7df2256e271c8d8d170791c6cd90ba9cc69f5e] internal/asan: match runtime.asan{read,write} len parameter type ... FAIL rsc.io/tmp/timertest/retry 20.148s Bisecting: 42 revisions left to test after this (roughly 5 steps) [c9ed561db438ba413ba8cfac0c292a615bda45a8] debug/elf: avoid using binary.Read() in NewFile() ... FAIL rsc.io/tmp/timertest/retry 14.146s Bisecting: 20 revisions left to test after this (roughly 4 steps) [2965dc989530e1f52d80408503be24ad2582871b] runtime: fix lost sleep causing TestZeroTimer flakes ... FAIL rsc.io/tmp/timertest/retry 18.152s Bisecting: 10 revisions left to test after this (roughly 3 steps) [b2e9221089f37400f309637b205f21af7dcb063b] runtime: fix another lock ordering problem ... ok rsc.io/tmp/timertest/retry 10.142s Bisecting: 5 revisions left to test after this (roughly 3 steps) [418e6d559e80e9d53e4a4c94656e8fb4bf72b343] os,internal/godebugs: add missing IncNonDefault calls ... ok rsc.io/tmp/timertest/retry 10.163s Bisecting: 2 revisions left to test after this (roughly 2 steps) [6133c1e4e202af2b2a6d4873d5a28ea3438e5554] internal/trace/v2: support old trace format ... FAIL rsc.io/tmp/timertest/retry 22.164s Bisecting: 0 revisions left to test after this (roughly 1 step) [508bb17edd04479622fad263cd702deac1c49157] time: garbage collect unstopped Tickers and Timers ... FAIL rsc.io/tmp/timertest/retry 16.159s Bisecting: 0 revisions left to test after this (roughly 0 steps) [74a0e3160d969fac27a65cd79a76214f6d1abbf5] time: clean up benchmarks ... ok rsc.io/tmp/timertest/retry 10.147s 508bb17edd04479622fad263cd702deac1c49157 is the first bad commit commit 508bb17edd04479622fad263cd702deac1c49157 Author: Russ Cox AuthorDate: Wed Feb 14 20:36:47 2024 -0500 Commit: Russ Cox CommitDate: Wed Mar 13 21:36:04 2024 +0000 time: garbage collect unstopped Tickers and Timers ... This CL adds an undocumented GODEBUG asynctimerchan=1 that will disable the change. The documentation happens in the CL 568341. ... bisect found first bad commit % ``` 这个错误似乎是由我的新的垃圾回收友好的定时器实现引起的,它将在Go 1.23中发布。*变魔术!*[](https://research.swtch.com/bisect#new_trick) ### [基于程序位置的二分搜索](https://research.swtch.com/bisect#new_trick) #### 寻找更高效的方法 `git bisect`定位到的有问题的提交,正是定时器实现的一次重大变更。考虑到这次改动可能会引发一些隐晦的测试失败,我特意加入了一个[GODEBUG设置](https://go.dev/doc/godebug),以便能够在新旧实现之间灵活切换。实际验证时,切换回旧实现后,相关错误果然消失了: ```bash % GODEBUG=asynctimerchan=1 go test -count=5 # old PASS ok rsc.io/tmp/timertest/retry 10.117s % GODEBUG=asynctimerchan=0 go test -count=5 # new --- FAIL: TestDo (4.00s) ... --- FAIL: TestDo (6.00s) ... --- FAIL: TestDo (4.00s) ... FAIL rsc.io/tmp/timertest/retry 18.133s % ``` 知道哪个提交导致了错误,以及关于失败的表现信息,通常足以帮助识别错误了。但如果不是呢?如果测试很大很复杂,完全是你从未见过的代码,它以某种难以理解的方式失败,似乎与你的更改无关怎么办?当你从事编译器或低级库的工作时,这种情况经常发生。为此,我们有一个新的魔法技巧:**基于程序位置的二分搜索**。 换句话说,我们**可以在不同的“维度”上进行二分搜索**:**不仅仅是在代码的版本历史上,还可以直接在程序的代码路径上进行**。我们将这种能力集成到了一个名为 `bisect` 的新工具中。当面对像定时器实现这样影响库函数行为的变更时,`bisect` 能够遍历所有可能触发新代码的调用堆栈,对部分堆栈启用新实现,对其他堆栈保持旧实现。通过多次迭代,工具最终可以将问题定位到只在特定堆栈启用新代码时才会出现失败。 ```bash % go install golang.org/x/tools/cmd/bisect@latest % bisect -godebug asynctimerchan=1 go test -count=5 ... bisect: FOUND failing change set --- change set #1 (disabling changes causes failure) internal/godebug.(*Setting).Value() /Users/rsc/go/src/internal/godebug/godebug.go:165 time.syncTimer() /Users/rsc/go/src/time/sleep.go:25 time.NewTimer() /Users/rsc/go/src/time/sleep.go:145 time.After() /Users/rsc/go/src/time/sleep.go:203 rsc.io/tmp/timertest/retry.Do() /Users/rsc/src/rsc.io/tmp/timertest/retry/retry.go:37 rsc.io/tmp/timertest/retry.TestDo() /Users/rsc/src/rsc.io/tmp/timertest/retry/retry_test.go:63 ``` 在这里,`bisect` 工具报告,仅需在这个特定调用堆栈上禁用 `asynctimerchan=1`(即启用新实现),就能复现测试失败。 调试时最棘手的问题之一,是逆向追踪程序的执行路径:当你发现某个数据结构出现异常值,或控制流偏离预期,往往很难还原它是如何一步步走到这一步的。而 `bisect` 工具的独特之处在于,它直接展示了问题发生*之前*的完整堆栈信息——也就是决定测试成败的关键分叉点。与其在茫茫代码中盲目向前推测,不如借助 `bisect` 精确定位到那些真正影响结果的几行代码。这样,我们可以聚焦于分析特定调用序列背后的实现逻辑,深入理解为何新定时器实现会引发行为变化。 对于在编译器或运行时等底层系统工作的人来说,若在庞大且陌生的代码库中引入了测试失败,`bisect` 能将原因缩小到几行关键代码,这种体验无疑令人惊叹。 接下来的内容将详细介绍 `bisect` 工具的内部原理。这一工具是 Keith Randall、David Chase 和我在过去十年 Go 语言开发过程中不断完善的成果。虽然类似的二分定位思想也被其他人和项目采用过,但我们在方法论和系统化实现上做了进一步拓展和总结。本文希望将这些经验分享出来,帮助更多人站在我们的基础上继续前进,而无需重复造轮子。[](https://research.swtch.com/bisect#example) #### [示例:查找函数优化引入的BUG](https://research.swtch.com/bisect#example) 我们先来看一个简单的例子,再回到堆栈跟踪的问题。假设你正在开发一个编译器,并且发现某个测试程序只有在开启优化编译时才会失败。此时,你可以列出程序中所有的函数,然后逐个尝试禁用某个函数的优化,直到找到一个最小的函数集合(有时甚至只有一个函数),只要优化这些函数就会触发错误。显然,二分搜索可以极大地加快这个定位过程: 1. 首先,修改编译器,让它输出所有被考虑优化的函数列表。 2. 然后,调整编译器,使其可以接收一个允许优化的函数列表。传递空列表(即不优化任何函数)时,测试应当通过;传递完整列表(优化所有函数)时,测试应当失败。 3. 利用二分搜索,找出能让测试失败的最短函数列表前缀。这个前缀的最后一个函数,是导致失败的必要优化目标之一(但可能不止一个)。 4. 将该函数始终设为优化,再重复上述过程,继续查找其他必须优化才能引发错误的函数。 举个例子,假设程序里有十个函数,我们依次进行三轮二分搜索实验: ![](assets/bisect/hashbisect0func.png) 当只优化前5个函数时,测试通过;优化前7个时,测试失败;优化前6个时,测试依然通过。这说明第7个函数`sin`是导致失败的关键之一。更具体地说,优化`sin`后,列表中后面的函数无需优化,但前面的函数是否也必须优化还不确定。为了进一步确认,我们可以在剩下的前六个函数中继续二分搜索,每次都加上`sin`: ![](assets/bisect/hashbisect0funcstep2.png) 这次,优化前两个(加上`sin`)会失败,优化第一个(加上`sin`)则通过,说明`cos`同样是必须优化的函数。最后只剩下`add`函数需要验证,结果发现即使去掉`add`,测试依然失败。 最终,我们得出结论:导致测试失败的一个局部最小函数集是`cos`和`sin`。只要从这个集合中去掉任何一个函数,测试就会通过。当然,这个集合未必是全局最小的,也许只优化`tan`会引发另一种失败(也可能不会)。但至少我们已经掌握了一组能够复现问题的最小优化组合。 #### 误区:可能的二分搜索认识误区 有些人可能会下意识地采用传统二分查找的方式:每次都把列表一分为二,先测试前半部分是否会导致错误。如果前半部分通过,就直接把它排除掉,接着只在后半部分继续查找。用在我们的例子中,这种算法的流程大致如下: ![](assets/bisect/hashbisect0funcbad.png) 第一次试验通过后,我们可能会以为问题出在列表的后半部分,于是直接舍弃了前半部分。然而,这样做的结果是,像 `cos` 这样的函数被排除在优化之外,后续的所有试验也都顺利通过,反而让我们无法再复现原本的失败。这其实暴露了一个核心问题:只有在确信某一部分对结果没有影响时,才能安全地将其移除。这个前提仅在错误由单一函数的优化引发时才成立,但实际情况往往更复杂——有时只有多个函数同时被优化才会触发问题。如果贸然丢弃一半列表,可能就把导致失败的组合也一并丢掉了。因此,二分搜索在这里应当以列表前缀的长度为基础,而不是简单地将列表一分为二。[](https://research.swtch.com/bisect#bisect-reduce) ### [Bisect-Reduce算法](https://research.swtch.com/bisect#bisect-reduce) #### 算法核心思想简介 前面提到的“重复二分搜索”虽然能够解决问题,但其实质是对传统二分查找的多次迭代,效率并不理想。为此,我们引入一种更为简洁高效的方案——“bisect-reduce”算法。这是一种以二分为基础、结合归约思想的策略,能够更直接地缩小问题范围,提升定位效率。 为了便于说明,我们假设有一个全局函数 `buggy`,用于判断当在指定的位置列表启用更改时,是否会触发错误: ```go // buggy reports whether the bug is triggered // by enabling the change at the listed locations. func buggy(locations []string) bool ``` `BisectReduce`函数接收一个输入列表`targets`,在`buggy(targets)`为真的前提下,返回一个局部最小的子集`x`,使得`buggy(x)`依然为真。其实现依赖于一个更通用的辅助函数`bisect`,该函数多了一个参数`forced`,用于在归约过程中始终保持启用的目标位置列表。 ```go // BisectReduce returns a locally minimal subset x of targets // where buggy(x) is true, assuming that buggy(targets) is true. func BisectReduce(targets []string) []string { return bisect(targets, []string{}) } // bisect returns a locally minimal subset x of targets // where buggy(x+forced) is true, assuming that // buggy(targets+forced) is true. // // Precondition: buggy(targets+forced) = true. // // Postcondition: buggy(result+forced) = true, // and buggy(x+forced) = false for any x ⊂ result. func bisect(targets []string, forced []string) []string { if len(targets) == 0 || buggy(forced) { // Targets are not needed at all. return []string{} } if len(targets) == 1 { // Reduced list to a single required entry. return []string{targets[0]} } // Split targets in half and reduce each side separately. m := len(targets)/2 left, right := targets[:m], targets[m:] leftReduced := bisect(left, slices.Concat(right, forced)) rightReduced := bisect(right, slices.Concat(leftReduced, forced)) return slices.Concat(leftReduced, rightReduced) } ``` 正如优秀的分治算法所展现的那样,这几行代码实际上蕴含了丰富的逻辑: * 当目标列表被缩减为空,或者`buggy(forced)`(即即使没有任何目标也会触发错误)返回真时,说明目标列表中的元素都不是必需的,此时可以直接返回空列表。反之,则意味着目标列表中至少有部分元素是不可或缺的。 * 若目标列表只剩下一个元素,则该元素必然是必要的,直接返回仅包含该元素的列表即可。 * 其他情况下,算法进入递归阶段:将目标列表一分为二,分别对左右两部分进行归约。这里有一个关键点:归约左半部分(left)时,强制保留的是 right+forced;归约右半部分时,强制保留的是已经归约得到的左半部分(`leftReduced`)加上 forced,而不是原始的 left。这一细节对于算法的正确性至关重要。 应用于函数优化例子,`BisectReduce`最终会调用 ```go bisect([add cos div exp mod mul sin sqr sub tan], []) ``` 这将把目标列表分成 ```go left = [add cos div exp mod] right = [mul sin sqr sub tan] ``` 递归调用计算: ```go bisect([add cos div exp mod], [mul sin sqr sub tan]) = [cos] bisect([mul sin sqr sub tan], [cos]) = [sin] ``` 然后`return`将两半放在一起:`[cos sin]`,这就是一个导致出现BUG的函数优化组合。 我们前面讨论的`BisectReduce`实现,是我见过的最为简洁的版本,可以称为“简单版本”。与之相对,还有一种实现方式更为详细复杂:它会优先处理当错误仅出现在一半目标中的情况,若无法满足,则进入必须组合两半目标的情形。我们可以将这种实现称为“精细版本”。 ```go // BisectReduce returns a locally minimal subset x of targets // where buggy(x) is true, assuming that buggy(targets) is true. func BisectReduce(targets []string) []string { if len(targets) == 0 || buggy(nil) { return nil } return bisect(targets, []string{}) } // bisect returns a locally minimal subset x of targets // where buggy(x+forced) is true, assuming that // buggy(targets+forced) is true. // // Precondition: buggy(targets+forced) = true, // and buggy(forced) = false. // // Postcondition: buggy(result+forced) = true, // and buggy(x+forced) = false for any x ⊂ result. // Also, if there are any valid single-element results, // then bisect returns one of them. func bisect(targets []string, forced []string) []string { if len(targets) == 1 { // Reduced list to a single required entry. return []string{targets[0]} } // Split targets in half. m := len(targets)/2 left, right := targets[:m], targets[m:] // If either half is sufficient by itself, focus there. if buggy(slices.Concat(left, forced)) { return bisect(left, forced) } if buggy(slices.Concat(right, forced)) { return bisect(right, forced) } // Otherwise need parts of both halves. leftReduced := bisect(left, slices.Concat(right, forced)) rightReduced := bisect(right, slices.Concat(leftReduced, forced)) return slices.Concat(leftReduced, rightReduced) } ``` 与简单版本算法相比,这个精细版本算法有两个主要优点: - 该算法更贴合我们对二分查找的直观理解:先尝试一边,再尝试另一边,如果都不行,再考虑两边的组合。这种分步处理方式让算法逻辑更加清晰易懂,而简洁版则始终依赖于通用情形,理解起来相对抽象。 - 该算法能够在存在单一罪魁祸首时,确保找到该唯一问题点。由于大多数实际错误最终都可以归结为单一原因,这一特性极大提升了调试效率。 例如,如果某次优化`tan`导致测试失败,该版本算法会优先定位到该单一问题点,从而简化调试过程。 ```go buggy([add cos div exp mod]) = false // left buggy([mul sin sqr sub tan]) = true // right ``` 然后会丢弃左侧,专注于右侧,最终找到`[tan]`,而不是`[sin cos]`。 那么缺点是什么呢?缺点就是没有找到`[sin cos]`吧 :) #### [基于列表的Bisect-Reduce](https://research.swtch.com/bisect#list-based_bisect-reduce) 在算法已经确立的基础上,接下来我们关注如何将其与编译器实际对接。具体来说,就是如何获取源代码中的位置列表,并将这些信息反馈给编译器以控制优化行为。 最直接的实现方式,是为编译器增加调试模式:一方面输出所有相关优化的位置列表,另一方面支持通过调试参数输入一个允许优化的位置列表。例如,Meta的Python Cinder JIT(2021年发布,详见[相关博客](https://bernsteinbear.com/blog/cinder-jit-bisect/))就采用了这种机制来决定哪些函数采用JIT编译。它的[`Tools/scripts/jitlist_bisect.py`](https://github.com/facebookincubator/cinder/blob/cinder/3.10/Tools/scripts/jitlist_bisect.py)脚本,是我所知最早正确实现bisect-reduce算法(精细版本算法)的开源实例。 这种方案的主要局限在于列表可能非常庞大,尤其是在需要对大型程序进行二分调试时,数据量会迅速膨胀。如果能减少每轮迭代需要传递给编译器的数据量,将极大提升效率。在复杂的构建系统中,函数列表可能大到无法通过命令行或环境变量传递,甚至难以为每次编译调用安排新的输入文件。因此,能够通过简短命令行参数指定目标列表的机制,在实际工程中会更加实用。[](https://research.swtch.com/bisect#counter-based_bisect-reduce) #### [基于计数器的Bisect-Reduce](https://research.swtch.com/bisect#counter-based_bisect-reduce) Java的HotSpot C2即时(JIT)编译器提供了一套调试机制,用于控制哪些函数会被JIT编译。与Cinder采用显式函数列表不同,HotSpot通过为待编译函数分配编号来实现管理。开发者可以通过编译器参数`-XX:CIStart`和`-XX:CIStop`来指定允许JIT编译的函数编号区间。这些参数[至今仍在调试版本中保留](https://github.com/openjdk/jdk/blob/151ef5d4d261c9fc740d3ccd64a70d3b9ccc1ab5/src/hotspot/share/compiler/compileBroker.cpp#L1569),其用法甚至可以在[2000年初的Java问题报告](https://bugs.java.com/bugdatabase/view_bug?bug_id=4311720)中找到踪迹。 然而,基于函数编号的方案存在至少两个局限: 首先,这种方式只支持对单一连续编号区间进行二分查找,因此仅适用于定位单一罪魁祸首的场景,无法直接支持多个问题点的通用bisect-reduce。如果要实现更通用的bisect-reduce,只需让参数支持多个编号区间即可,例如`-XX:CIAllow=1-5,7-10,12,15`。 其次,更为棘手的问题在于,函数编号在不同运行之间难以保持稳定。比如,采用并行编译时,线程间的调度顺序可能导致函数被赋予不同的编号;在JIT场景下,甚至运行时的线程行为也会影响函数的编号顺序。二十多年前,线程用得较少,这一问题影响有限;但在今天,依赖编号一致性已成为实际应用中的一大障碍。[](https://research.swtch.com/bisect#hash-based_bisect-reduce) #### [New!!!: 基于哈希的Bisect-Reduce](https://research.swtch.com/bisect#hash-based_bisect-reduce) 在前文中,我们依次介绍了基于列表、计数器的 bisect-reduce 算法及其在实际工程中的应用与局限。可以看到,随着程序规模的扩大和并发编译的普及,传统的列表或编号方式在可扩展性和稳定性上都面临挑战。为此,下一节将聚焦于一种更为通用且健壮的方案——基于哈希的 bisect-reduce 方法。该方法通过哈希映射实现对位置集合的高效、无序管理,极大提升了算法在复杂系统中的适用性。 接下来,我们将详细解析其原理与实现细节,并探讨其在现代编译器和调试工具中的实际价值。 ### [基于哈希的Bisect-Reduce详解](https://research.swtch.com/bisect#hash-based_bisect-reduce) 在前述基于列表和编号的 bisect-reduce 方法基础上,我们进一步引入了一种更为灵活和健壮的实现思路——基于哈希的隐式位置表示。具体做法是,将每个目标位置通过哈希函数映射为一个(近似随机的)整数,再利用二进制后缀来描述和筛选一组位置。由于哈希值的生成与源代码遍历顺序无关,这种方式天然适合并行编译、多线程等复杂场景,极大提升了算法的可扩展性和稳定性。 借助哈希映射,所有函数或位置实际上被组织成一棵二叉树,为后续的高效定位和归约操作奠定了基础: ![](assets/bisect/hashbisect1.png) 定位单一问题点时,只需沿着这棵树逐步下探即可。而更通用的bisect-reduce算法,也可以很自然地迁移到哈希后缀的框架下。为此,我们需要稍作调整`buggy`的定义:它不仅要判断当前后缀对应的位置集是否会触发bug,还要返回该后缀实际匹配的位置数量,这样才能判断是否已经归约到最小单元: ```go // buggy reports whether the bug is triggered // by enabling the change at the locations with // hashes ending in suffix or any of the extra suffixes. // It also returns the number of locations found that // end in suffix (only suffix, ignoring extra). func buggy(suffix string, extra []string) (fail bool, n int) ``` 现在我们可以直译一下精细版本的算法实现,大致是这样: ```go // BisectReduce returns a locally minimal list of hash suffixes, // each of which uniquely identifies a single location hash, // such that buggy(list) is true. func BisectReduce() []string { if fail, _ := buggy("none", nil); fail { return nil } return bisect("", []string{}) } // bisect returns a locally minimal list of hash suffixes, // each of which uniquely identifies a single location hash, // and all of which end in suffix, // such that buggy(result+forced) = true. // // Precondition: buggy(suffix, forced) = true, _. // and buggy("none", forced) = false, 0. // // Postcondition: buggy("none", result+forced) = true, 0; // each suffix in result matches a single location hash; // and buggy("none", x+forced) = false for any x ⊂ result. // Also, if there are any valid single-element results, // then bisect returns one of them. func bisect(suffix string, forced []string) []string { if _, n := buggy(suffix, forced); n == 1 { // Suffix identifies a single location. return []string{suffix} } // If either of 0suffix or 1suffix is sufficient // by itself, focus there. if fail, _ := buggy("0"+suffix, forced); fail { return bisect("0"+suffix, forced) } if fail, _ := buggy("1"+suffix, forced); fail { return bisect("1"+suffix, forced) } // Matches from both extensions are needed. // Otherwise need parts of both halves. leftReduced := bisect("0"+suffix, slices.Concat([]string{"1"+suffix}, forced)) rightReduced := bisect("1"+suffix, slices.Concat(leftReduced, forced)) return slices.Concat(leftReduce, rightReduce) } ``` 细心的读者可能已经注意到,在简单场景下,`bisect`的递归调用往往会重复执行与调用者相同的`buggy`检测,以统计当前后缀匹配的数量。为了提升效率,理想的做法是将已有的检测结果直接传递给递归调用,避免重复试验。 此外,这个版本的`bisect`并不保证每次递归都能将搜索空间严格对半分。实际上,由于哈希的随机性,平均而言搜索空间会被大致平分。当潜在的罪魁祸首数量较少时,这种方式依然能够实现对数级别的查找效率。如果后缀用于匹配有序编号而非哈希,算法同样适用,唯一的难点在于如何为元素分配编号。 值得一提的是,哈希后缀与函数编号的长度相当,因此非常适合在命令行参数中传递。例如,假设某个Java编译器可以通过`-XX:CIAllowHash=000,10,111`这样的参数来指定。[](https://research.swtch.com/bisect#use_case) ### Go编译器+运行时中的DEBUG实践 本节介绍了 bisect 算法在 Go 编译器及运行时中的实际应用和工程实践。通过前面的分析,我们已经理解了 bisect-reduce 的基本原理及其在定位复杂问题时的高效性。接下来,我们将结合具体的用例,详细剖析 bisect 技术如何在真实项目中帮助开发者快速定位和解决难以复现的 bug,并探讨其在不同场景下的灵活变体与演进过程。 #### [用例:函数选择](https://research.swtch.com/bisect#use_case) Go中基于哈希的bisect-reduce最早的实际应用场景,就是用于函数选择,这也是我们前文反复讨论的典型例子。2015年,Keith Randall在为Go编译器开发全新的SSA后端时,面临新旧后端并存、可针对任意函数灵活切换的问题。为此,Keith设计了一个[环境变量GOSSAHASH](https://go.googlesource.com/go/+/e3869a6b65bb0f95dac7eca3d86055160b12589f),用于指定哪些函数应采用新后端。具体做法是:GOSSAHASH的值为一串二进制位(如GOSSAHASH=0110),表示“仅对函数名哈希值末4位为0110的函数使用新后端”。当新后端导致测试失败时,调试者可以先尝试GOSSAHASH=0和GOSSAHASH=1,然后通过二分法逐步细化匹配模式,最终定位到唯一一个用新后端编译的函数,从而精准锁定问题根源。这种方法在面对庞大、复杂且并非自己编写的真实世界测试(如库或生产代码测试)时,极大提升了调试效率。需要注意的是,这一策略假设每次失败都能归因于某个特定的函数。 有趣的是,HotSpot、Cinder和Go三大项目都独立采用了二分搜索来定位编译器中的错误编译函数,但各自实现的选择机制却各不相同:HotSpot用计数器,Cinder用函数列表,Go则采用哈希后缀。[](https://research.swtch.com/bisect#use_case) #### [用例:SSA重写选择](https://research.swtch.com/bisect#use_case) 2016年底,David Chase在调试一条新的优化器重写规则时,遇到了本应正确却引发神秘测试失败的情况。为了解决这个问题,他[沿用了之前的二分定位技术](https://go-review.googlesource.com/29273),但将控制粒度进一步细化:通过位模式来限定哪些函数可以应用该重写规则。 此外,David还开发了[工具`gossahash`的初版](https://github.com/dr2chase/gossahash/tree/e0bba144af8b1cc8325650ea8fbe3a5c946eb138),专门用于自动化二分查找过程。尽管`gossahash`最初只能定位单一的失败点,但它在实际调试中表现出极高的实用价值,并被沿用多年,最终演化为今天的`bisect`工具。[](https://research.swtch.com/bisect#use_case) #### [用例:融合乘加](https://research.swtch.com/bisect#use_case) 有了自动化的工具,我们无需再手动进行二分查找,能够持续高效地定位各种问题。2022年,团队在为Go编译器引入新架构下的浮点融合乘加(FMA)指令时,遇到了一些测试失败的情况。为此,我们让FMA相关的重写逻辑依赖于当前文件名和行号的哈希后缀,从而借助bisect-reduce方法,精准定位到导致测试失败的具体源代码行。 例如,借助该二分查找工具,最终定位到`b.go:7`这一行是问题根源: ![](assets/bisect/hashbisect0.png) FMA指令并非大多数程序员日常会接触的内容,但一旦因其引发测试失败,能够自动识别出具体出错行的工具就显得尤为宝贵。[](https://research.swtch.com/bisect#use_case) #### [用例:语言特性变更](https://research.swtch.com/bisect#use_case) 另一个典型应用场景是语言特性的变更。以Go为例,和C#、JavaScript等语言一样,Go在发展过程中逐渐意识到“循环作用域下的循环变量”与闭包、并发等特性结合时容易引发问题。为了解决这一历史遗留问题,Go近期也采用了[迭代作用域循环变量](https://go.dev/blog/loopvar-preview)的方案,从而修正了许多原本存在缺陷的程序。 然而,现实中有些测试用例其实是在无意中依赖了原本的错误行为。当我们在大型代码库中推广循环变量的语义变更时,常常会在陌生且复杂的代码中遇到难以解释的失败。此时,如果将循环变量的行为变更与源文件名和行号的哈希后缀绑定,就可以借助bisect-reduce方法,精准定位到导致测试失败的具体循环或循环组合。实践中我们甚至发现,有些情况下单独更改任意一个循环并不会导致失败,只有特定的循环组合被同时更改时才会触发问题,因此支持定位多个“罪魁祸首”也非常重要。 如果没有自动化诊断工具,类似的循环变量语义变更将变得更加棘手和低效。[](https://research.swtch.com/bisect#use_case) #### [用例:库更改](https://research.swtch.com/bisect#use_case) Bisect-reduce 同样非常适合用于库的变更场景:我们可以对调用者,或者更准确地说,对调用堆栈进行哈希,然后根据哈希后缀,在新旧实现之间动态切换。 举个例子,假设你为一个大型程序引入了新的排序实现,结果程序出现了失败。即便新排序算法本身没有问题,失败往往也源于新旧排序在处理相等元素时的顺序差异。当然,也不排除排序实现本身存在缺陷。无论是哪种情况,大型程序中排序函数可能被多处调用。此时,基于调用堆栈哈希运行 bisect-reduce,就能精准定位到因采用新排序而导致失败的具体调用堆栈。这正如本文开头的例子所示,只不过当时是定时器实现的切换,而非排序。 调用堆栈的这种用法只适合哈希,不适合顺序编号。前面提到的其他例子中,我们可以为程序中的所有函数或源代码行分配编号,然后用 bisect-reduce 针对编号的二进制后缀进行筛查。但对于调用堆栈,几乎不可能为所有可能出现的堆栈分配密集的顺序编号,而对程序计数器列表进行哈希却非常简单高效。 我们是在引入 [GODEBUG 机制](https://go.dev/doc/godebug) 时意识到 bisect-reduce 对库变更同样适用的。GODEBUG 提供了一个框架,用于跟踪和切换这些兼容但可能破坏性的更改。我们也让该框架自动为所有 GODEBUG 设置集成了 `bisect` 支持。 以 Go 1.23 为例,我们重写了 [time.Timer](https://go.dev/pkg/time/#Timer) 的实现,并对其语义做了微调,消除了原有 API 中的一些竞争条件,同时让垃圾回收在某些常见场景下能更早发生。新实现的一个显著效果是极短定时器的触发变得更加可靠。以往 0ns 或 1ns 的定时器(常见于测试)可能需要数微秒才能触发,而现在能准时响应。当然,这也暴露出一些原本隐藏的代码缺陷(主要出现在测试中):当定时器按预期时间触发时,反而导致测试失败。我们在 Google 的代码库中调试了十几个类似的问题——这些问题都很复杂且陌生——而 `bisect` 工具让整个定位过程变得轻松甚至有趣。 有一次,我在排查一个失败的测试用例时犯了个错误。这个测试看起来很简单,似乎可以直接通过代码阅读找出原因,于是我花了半小时反复琢磨唯一的定时器——一个硬编码为一分钟的定时器——怎么会受到新实现的影响。最终我选择放弃,转而运行 `bisect`。结果堆栈跟踪立刻揭示,原来有一层测试中间件会把一分钟的超时重写为 1ns,以加快测试速度。工具能发现人眼难以察觉的细节。[](https://research.swtch.com/bisect#interesting_lessons_learned) ### [学到的有趣经验](https://research.swtch.com/bisect#interesting_lessons_learned) 在开发 `bisect` 工具的过程中,我们有两个值得分享的经验体会。 首先,检测测试用例的不稳定性至关重要。早期在调试循环相关的失败时,`bisect` 曾经定位到加密包中一个本应完全正确、毫无问题的循环。最初我们非常紧张,担心编译器出现了严重的隐藏错误。深入分析后才发现,根本原因在于测试本身存在随机性——测试偶尔失败,导致 `bisect` 在代码中“随机游走”,最终指向了无辜的代码。为此,我们为 `bisect` 增加了 `-count=N` 选项,让每次试验自动重复 N 次,若结果不一致则直接终止。默认值设为 `-count=2`,以确保每次都能做基本的不稳定性检测。 ```bash % cat bin/allpass ##!/bin/sh n=$1 shift for i in $(seq $n); do "$@" || exit 1 done ``` 然后可以这样调用`bisect`: ```bash % bisect -godebug=timer allpass 5 ./flakytest ``` 现在bisect只看到`./flakytest`连续通过五次作为成功运行。 类似地,如果测试从不可靠地通过变为一直失败,可以使用`anypass`变体: ```bash % cat bin/anypass ##!/bin/sh n=$1 shift for i in $(seq $n); do "$@" && exit 0 done exit 1 ``` 如果更改导致测试一直运行而不是直接失败,此时可以借助 [`timeout` 命令](https://man7.org/linux/man-pages/man1/timeout.1.html)来辅助控制测试超时时间,避免测试陷入无休止的等待。 虽然通过外部工具来应对测试不稳定性在实际中效果不错,但这并非终极方案。如果能在 `bisect` 工具内部引入更系统、更有原则的机制,将会更加理想。我们目前仍在探索更完善的实现方式。 另一个有趣的经验是:在对运行时行为进行二分调试时,哈希决策的频率极高。如果在 bisect-reduce 的每个阶段都输出完整的堆栈跟踪,开销会非常大(尤其是在初始阶段,所有哈希后缀都匹配时)。因此,bisect 的哈希模式默认采用“安静”模式,仅输出每次决策的哈希位,这已经足够支撑 bisect 的搜索和定位过程。当 bisect 最终确定了最小相关堆栈集合后,会切换到“详细”模式重新运行测试,此时 bisect 库会输出哈希位及对应的堆栈跟踪,最终这些信息会被 bisect 工具收集并展示在报告中。[](https://research.swtch.com/bisect#try_bisect) ### [尝试工具Bisect](https://research.swtch.com/bisect#try_bisect) [`bisect`工具](https://pkg.go.dev/golang.org/x/tools/cmd/bisect),您可以这样安装bisect工具: ```bash % go install golang.org/x/tools/cmd/bisect@latest ``` 如果你正在调试Go 1.22中的[循环变量问题](https://go.dev/wiki/LoopvarExperiment),你可以使用这样的命令: ```bash % bisect -compile=loopvar go test ``` 如果你正在调试[Go 1.23中的定时器问题](https://go.dev/change/966609ad9e82ba173bcc8f57f4bfc35a86a62c8a),你可以使用: ```bash % bisect -godebug asynctimerchan=1 go test ``` `-compile`和`-godebug`标志是便利功能。命令的一般形式是 ```bash % bisect [KEY=value...] cmd [args...] ``` 在命令行中,前置的 `KEY=value` 参数会在执行后续命令前设置相应的环境变量。`bisect` 工具要求在命令行参数中包含字面量字符串 `PATTERN`,每次运行时会将其替换为当前的哈希模式。 你可以利用 `bisect` 来调试自己编写的编译器或库,只需让你的程序支持通过环境变量或命令行参数接收哈希模式,并在标准输出或标准错误中输出特定格式的信息供 `bisect` 解析。最便捷的做法是直接使用 [bisect 包](https://pkg.go.dev/golang.org/x/tools/internal/bisect)。目前该包尚未正式纳入 Go 标准库(参见[相关提案](https://go.dev/issue/67140)),但它本身只是一个[无依赖的单文件实现](https://cs.opensource.google/go/x/tools/+/master:internal/bisect/bisect.go),可以轻松复制到你的项目中,甚至移植到其他语言。包文档中还详细说明了哈希模式的语法及所需的输出格式。 如果你在开发编译器或库时,遇到“明明做了正确修改却导致复杂程序出错”的棘手问题,不妨试试 `bisect`。它的调试体验总能带来意想不到的惊喜。 ### 本节小结 本节围绕编译器和运行时中的哈希二分调试技术展开,梳理了二分搜索在调试领域的历史演变,从最初的版本回溯二分到针对程序内部位置的哈希二分。重点解析了 bisect-reduce 算法的核心原理、实现方式及其在定位复杂问题时的高效性。并且通过与基于列表、基于计数器的二分调试技术进行对比,阐明了基于哈希的二分调试技术的优势,并且展示了它如何使用、如何显著提升调试效率,尤其是在大规模代码和难以复现的错误场景下。然后介绍了该方法在 Go 语言编译器、标准库等项目中进行问题定位的真实案例,表明了哈希二分调试方法的价值。 需要特别关注的是,该哈希二分调试技术,已经沉淀为了工具、公共库,如果你也想为你的项目开启哈希二分调试支持,也可以通过集成相关操作来予以实现。感兴趣的话可以了解下下面工具和库的使用: - 配套的命令行工具,https://github.com/golang/tools/tree/master/cmd/bisect - 代码添加开关位置,https://github.com/golang/tools/tree/master/internal/bisect ### 参考内容 1. hash-based bisect debugging in compilers and runtimes, https://research.swtch.com/bisect 2. vscode extension bisect, https://code.visualstudio.com/blogs/2021/02/16/extension-bisect 3. git bisect, https://git-scm.com/docs/git-bisect ================================================ FILE: book/10-extras/6-development-of-ebpf.md ================================================ ## eBPF技术的发展历程与未来展望 ### 1. eBPF诞生的问题背景 操作系统跟踪和监控一直是系统性能分析和故障排查的重要手段。在eBPF诞生之前,Linux系统中存在多种跟踪技术,但它们各自为政,缺乏统一性和灵活性: #### 传统跟踪技术的局限 * **动态跟踪(kprobe/uprobe)** :允许在内核或用户空间函数入口和返回处插入探针,但使用复杂且需要特殊工具链。 * **静态跟踪(tracepoint)** :内核中预定义的静态检测点,覆盖面有限。 * **硬件性能计数器(PMC)** :提供硬件级事件监控,但难以与软件层面事件关联。 * **定时器抽样** :如perf采样,开销大且可能错过关键事件。 这些工具各自成体系,使用方式不统一,导致学习和使用成本高昂。更重要的是,它们大多数需要特权访问,无法提供细粒度的安全控制。 #### 系统监控的痛点 在云计算兴起的背景下,传统跟踪技术面临几个关键挑战: 1. **性能开销** :许多跟踪工具会引入显著的性能损耗,不适合生产环境。 2. **安全隐患** :部分工具需要root权限,可能导致系统不稳定或安全风险。 3. **可扩展性** :随着系统规模扩大,跟踪点数量激增,分析变得困难。 4. **灵活性不足** :很难根据特定需求定制跟踪行为。 正是在这一背景下,eBPF(extended Berkeley Packet Filter)技术应运而生。 ### 2. eBPF系统的发展历程 #### 初期:从BPF到eBPF 1992年,Steven McCanne和Van Jacobson在Lawrence Berkeley National Laboratory开发了原始的BPF(Berkeley Packet Filter),最初用于网络数据包过滤。它允许用户空间程序指定过滤条件,只接收感兴趣的数据包,大大提高了网络监控工具的效率。 2014年,Alexei Starovoitov对BPF进行了重大改进,引入了eBPF(extended BPF)。这一改进扩展了BPF的功能,使其不再局限于网络数据包过滤,而是成为一个通用的内核内虚拟机。 #### 关键里程碑 ##### Linux 3.15(2014年):eBPF首次引入 * 增加了eBPF的基础架构,包括JIT(Just-In-Time)编译器 * 扩展了指令集,支持更复杂的操作 ##### Linux 3.18(2014年):kprobe支持 * eBPF程序可以附加到kprobe上,实现动态内核跟踪 ##### Linux 4.1(2015年):地图(Maps)功能 * 引入了BPF Maps,作为eBPF程序与用户空间通信的数据结构 * 使得数据存储和共享变得可能 ##### Linux 4.4(2016年):tracepoint支持 * eBPF可以附加到静态tracepoint * 提供了更加稳定的跟踪接口 ##### Linux 4.7(2016年):perf支持 * 与Linux perf工具集成,提供更强大的性能分析能力 ##### Linux 4.8(2016年):XDP(eXpress Data Path) * 引入高性能网络数据包处理技术 * 数据包在到达常规网络栈之前就可被处理 ##### Linux 4.9(2016年):BPF Type Format(BTF)初步支持 * 为eBPF程序提供更丰富的类型信息 * 开始支持CO-RE(Compile Once, Run Everywhere) ##### Linux 4.10(2017年):cgroup支持 * 允许eBPF程序与cgroup结合,实现更细粒度的控制 ##### Linux 4.12(2017年):硬件性能计数器(PMC)支持 * 支持硬件事件监控 ##### Linux 4.14(2017年):uprobe支持 * 允许eBPF程序附加到用户空间函数 * 扩展了跟踪范围 ##### Linux 4.15(2018年):socket支持 * 增强了网络相关功能 ##### Linux 4.18(2018年):BPF to BPF函数调用 * 允许eBPF程序之间互相调用,提高了代码复用能力 ##### Linux 5.0(2019年):原生结构化日志支持 * 引入BPF_TRACE_PRINTK,简化了日志记录 ##### Linux 5.10(2020年):完整的BTF支持 * 完善了BTF元数据,增强了CO-RE能力 ##### Linux 5.13(2021年):BPF LSM(Linux Security Module) * 允许使用eBPF编写安全策略 #### 代表性工具和框架 * **BCC(BPF Compiler Collection)** :2015年推出,提供了一套用于创建eBPF程序的工具和库。 * **bpftrace** :2018年推出,提供了类似DTrace的高级脚本语言,简化了eBPF程序的开发。 * **Cilium** :2017年推出,基于eBPF的网络安全和可观察性解决方案。 * **Falco** :2016年推出,利用eBPF进行云原生应用安全监控。 * **Hubble** :2020年推出,为Kubernetes提供基于eBPF的网络可视化工具。 #### 教训与突破 ##### 案例1:Netflix的性能优化之旅 Netflix在2016年采用eBPF进行性能分析,发现了一个长期存在但难以检测的TCP缓冲区问题。传统工具无法发现这一问题,因为它需要同时跟踪网络栈和应用层。eBPF帮助他们找出了系统瓶颈,提高了服务响应时间。 ##### 案例2:Google的BPF安全漏洞 2017年,Google发现了一个eBPF验证器中的安全漏洞(CVE-2017-16995),该漏洞可以被利用来实现本地权限提升。这一事件促使了eBPF安全模型的全面审查,最终导致了更加严格的验证机制。 ##### 案例3:Facebook的网络优化 Facebook(现为Meta)在2018年利用eBPF的XDP功能构建了DDoS防御系统。之前,他们的网络防御系统需要专用硬件。采用eBPF后,他们能够在标准服务器上实现高效的DDoS防御,显著降低了成本。 ### 3. 分布式系统时代的eBPF挑战与机遇 #### 微服务架构的挑战 随着微服务架构的普及,应用程序分解为多个相互通信的小型服务,这为监控和跟踪带来了新的挑战: 1. **服务间通信** :难以追踪跨服务请求的完整路径。 2. **根因分析** :故障可能源于多个服务之间的复杂交互。 3. **性能开销** :传统监控工具可能对轻量级服务造成过大的性能影响。 eBPF在这方面有独特优势: * **低开销** :eBPF程序直接在内核中执行,减少了上下文切换。 * **细粒度洞察** :可以跟踪网络、系统调用、应用程序等各个层面。 * **安全性** :验证器确保eBPF程序不会崩溃或无限循环。 #### 云原生环境的应用 在Kubernetes等云原生环境中,eBPF正在重塑可观察性和网络安全: ##### 网络策略实施 * **Cilium** :利用eBPF实现Kubernetes网络策略,提供比传统iptables更高效的网络隔离。 * **性能优势** :相比传统的iptables,eBPF可以实现更高的吞吐量和更低的延迟。 ##### 服务网格 * **eBPF基础的Service mesh** :替代传统的基于Sidecar的方案,减少了资源开销。 * **例如** :Cilium的Hubble提供了服务网格功能,无需额外的代理。 ##### 安全监控 * **系统调用监控** :检测异常行为,如权限升级尝试。 * **运行时安全** :实时监控容器行为,确保符合安全策略。 #### 可能的发展方向 1. **eBPF作为内核可编程性的统一接口** : * 简化内核扩展开发 * 降低新功能引入的风险 1. **跨平台支持** : * 扩展到非Linux系统 * 目前已有Windows eBPF项目 1. **硬件加速** : * 利用SmartNIC等专用硬件加速eBPF程序 * 降低CPU开销 1. **自动问题检测与修复** : * 利用eBPF构建自动化故障检测系统 * 实现自愈功能 #### 应用潜力 1. **负载均衡** : * 高性能的L4/L7负载均衡器 * 动态调整流量分配 1. **可观察性** : * 深入了解应用性能和行为 * 跨服务追踪 1. **安全增强** : * 实时入侵检测 * 零信任网络实现 1. **网络优化** : * 智能路由 * 流量整形 ### 4. 人工智能时代的eBPF 随着人工智能和机器学习的快速发展,eBPF面临新的机遇和挑战。 #### AI工作负载的监控与优化 AI工作负载与传统应用有很大不同,它们往往: * 需要大量的计算资源 * 有复杂的内存访问模式 * 依赖专用的硬件加速器(如GPU、TPU) eBPF可以通过以下方式优化AI工作负载: 1. **资源利用率监控** : * 实时跟踪GPU/TPU使用情况 * 监控内存带宽消耗 1. **IO优化** : * 识别数据加载瓶颈 * 优化存储访问模式 1. **智能调度** : * 根据工作负载特性动态分配资源 * 优化多租户环境中的资源共享 #### AI与eBPF协同优化 另一个有前景的方向是利用AI来优化eBPF程序本身: 1. **自动化程序生成** : * 使用AI生成特定场景的eBPF程序 * 简化开发流程 1. **异常检测** : * 利用机器学习模型分析eBPF收集的数据 * 自动发现异常模式 1. **预测性维护** : * 基于历史数据预测系统问题 * 提前采取措施防止故障 #### 跨领域应用 eBPF与AI的结合将为多个领域带来变革: 1. **自动驾驶系统** : * 实时监控车载系统性能 * 确保关键组件的可靠性 1. **边缘计算** : * 在资源受限的设备上优化AI推理 * 减少网络延迟和带宽消耗 1. **医疗设备** : * 监控关键医疗系统的性能和安全 * 确保医疗AI应用的可靠性 #### 未来发展方向 1. **eBPF加速器** : * 专用硬件加速eBPF程序执行 * 降低处理开销 1. **统一的可观察性框架** : * 整合系统、应用和AI模型的监控 * 提供端到端的性能分析 1. **自适应安全** : * 基于AI和eBPF构建自适应安全系统 * 动态调整安全策略 1. **量子计算准备** : * 扩展eBPF以支持量子计算环境 * 为量子-经典混合系统提供监控能力 1. **AI工作流优化** : * 利用eBPF优化AI训练和推理流程 * 提高资源利用率和能效 ### 5. 总结与展望(续) #### 当前价值 * **统一的可观察性** :eBPF提供了一个统一的框架,整合了动态跟踪、静态跟踪和硬件监控等多种技术,使系统可观察性变得更加全面和一致。 * **安全增强** :通过细粒度的访问控制和安全验证,eBPF可以在不牺牲安全性的前提下提供强大的系统可观察性和网络控制能力。 * **性能优化** :eBPF程序直接在内核中执行,避免了频繁的上下文切换,大大降低了监控和网络处理的开销。 * **灵活性** :开发者可以编写自定义的eBPF程序,满足特定场景的需求,而不需要修改内核代码或加载内核模块。 #### 未来展望 随着技术的不断发展,eBPF的应用前景将更加广阔: 1. **全栈可观察性** : * 从硬件到应用层的全方位监控 * 实时数据分析和问题诊断 1. **网络现代化** : * 替代传统的网络栈组件 * 更高效的协议实现和路由决策 1. **安全革新** : * 从被动检测到主动防御 * 细粒度的安全策略执行 1. **云原生生态整合** : * 与Kubernetes、服务网格等更深入的集成 * 成为云原生基础设施的核心组件 1. **跨平台标准化** : * 扩展到更多操作系统和平台 * 建立统一的接口标准 #### 面临的挑战 尽管eBPF前景广阔,但仍面临一些挑战: 1. **学习曲线** : * 复杂的概念和编程模型 * 需要深入理解内核机制 1. **调试困难** : * 内核级别的调试比用户空间更复杂 * 错误处理机制有限 1. **版本兼容性** : * 不同内核版本支持的功能差异 * CO-RE机制仍在完善中 1. **生态系统成熟度** : * 工具链和开发环境尚需改进 * 社区支持和文档体系建设 #### 结语 eBPF代表了Linux系统可编程性的未来方向。通过提供一个安全、高效、灵活的执行环境,eBPF正在重新定义我们与操作系统内核交互的方式。从网络数据包过滤到全面的系统可观察性,从简单的计数器到复杂的安全策略执行,eBPF已经证明了其作为系统扩展机制的强大潜力。 在分布式系统、云原生和人工智能的推动下,eBPF将继续演进,为解决现代计算环境中的挑战提供创新解决方案。随着社区的不断壮大和技术的持续完善,eBPF有望成为未来操作系统设计和实现的核心组成部分,为系统可观察性、网络和安全领域带来更多突破。 无论是系统管理员、开发人员还是安全专家,都应该关注eBPF技术的发展,掌握这一强大工具,以应对日益复杂的IT环境挑战。eBPF不仅是一项技术创新,更是一种全新的系统交互范式,它将继续重塑我们构建、监控和保护计算系统的方式。 ================================================ FILE: book/10-extras/7-development-of-replay.md ================================================ ## 确定性重放解决方案的发展历程 ### 1. 确定性重放解决方案诞生的问题背景 软件开发中,调试(Debugging)一直是开发者面临的最具挑战性的任务之一。传统调试方法如打印日志、设置断点等在处理复杂系统时显得力不从心,特别是面对以下问题: * **Heisenbugs** : 这类bug在观察时会改变行为或消失,使其难以重现和修复 * **时序相关的并发问题** : 多线程环境中的竞态条件可能仅在特定执行顺序下出现 * **非确定性行为** : 系统可能因随机数生成、线程调度、I/O操作等因素导致每次运行结果不同 * **难以复现的生产环境问题** : 在客户环境中发生的问题在开发环境中可能无法重现 这些挑战导致调试过程耗时且低效,严重影响开发效率和软件质量。为解决这些问题,确定性重放(Deterministic Replay)技术应运而生。 ### 2. 确定性重放的思想与发展历程 #### 确定性重放的基本思想 确定性重放的核心思想是: **记录程序执行过程中的非确定性事件,并在重放阶段精确重现这些事件,使程序的执行路径与原始执行完全一致** 。这使开发者能够: * 多次重放相同的执行路径进行调试 * 向前和向后遍历程序状态 * 分析程序行为而不影响其执行 #### 早期探索 (1990年代-2000年代初) 确定性重放技术的研究始于上世纪90年代的学术界: * **Instant Replay (1987)** : 由莱斯大学提出的早期概念验证系统,专注于多处理器环境中的共享内存访问记录 * **Amber (1991)** : 一个为分布式系统设计的确定性重放框架,聚焦消息传递的记录和重放 * **DejaVu (1998)** : Java虚拟机级别的确定性重放系统,记录线程调度和I/O操作 这些早期系统主要在学术环境中使用,存在性能开销大、可用性差的问题,未能在实际开发中广泛应用。 #### 商业化尝试与挫折 (2000年代) * **Reversible Debugger (2003-2005)** : 微软研究院开发的确定性重放原型,后来启发了部分Visual Studio调试功能 * **Green Hills TimeMachine (2004)** : 嵌入式系统领域的商业重放调试器,但仅限于特定硬件平台 * **Replay Solutions (2006-2012)** : 一家尝试将确定性重放商业化的创业公司,最终因技术困难和市场接受度不足而失败 这一时期的惨痛教训在于,全面的确定性重放在通用计算环境中实现成本过高,商业产品难以平衡性能、可用性和兼容性。 #### Mozilla RR: 实用确定性重放的突破 (2011年至今) Mozilla Research在2011年启动的rr (record and replay)项目标志着确定性重放技术的重要突破: * **轻量级设计** : 聚焦于Linux平台下的x86处理器,精简了设计目标 * **低开销记录** : 通过创新技术如硬件性能计数器减少记录阶段的性能影响 * **与GDB集成** : 利用开发者熟悉的调试工具界面,降低学习成本 * **开源模式** : 促进社区贡献和技术改进 Mozilla RR成功的关键在于其设计哲学: **不追求解决所有问题,而是聚焦于最常见、最有价值的应用场景** 。它主要关注单进程应用程序,不尝试解决分布式系统的全部挑战。 > ps: GDB内置的record/replay模式,和mozilla rr有关系吗? > > **GDB 的内置 record/replay 模式(`record full`等)是一个独立的功能,它**不依赖** Mozilla 的 rr (Record and Replay) 工具。** > > 不过,它们之间有密切的关系和区别: > > 1. **GDB 内置的 Record/Replay:** > * 这是 GDB 本身的功能(使用 `record` 命令)。 > * 它的工作原理通常是记录每一条机器指令的执行和状态变化(例如 `record full`),因此通常 **速度较慢** ,且对内存消耗较大,有时被称为“指令级记录/回放”。 > * 它支持多种架构,但它的性能和功能(例如对多线程的支持)在某些情况下不如 rr。 > 2. **Mozilla 的 rr (Record and Replay):** > * 这是一个 **独立的工具** ,最初由 Mozilla 开发者开发,用于调试 Firefox。 > * 它使用了一种更高效的方法,通过拦截系统调用和记录异步事件(例如使用硬件性能计数器)来确保确定性回放,因此通常 **开销更低** 。 > * rr 在回放时是作为一个 GDB **server** (调试服务端)来运行的,而你可以用标准的 GDB **client** (调试客户端)连接到它,并使用 GDB 的反向调试命令(如 `reverse-continue`, `reverse-next` 等)。 > * rr 主要是针对 **x86_64 架构上的 Linux 用户空间程序**设计的。 > > **总结来说:** GDB 有自己的内置记录/回放机制,这是一个独立的功能。但由于 GDB 内置的记录/回放性能和扩展性限制(尤其是在面对像 Firefox 这样的大型多线程应用时), **rr 成为了在 Linux/x86_64 上进行高性能记录/回放调试的一个流行且更优的选择** ,它通过 GDB 的远程调试协议来提供服务,让用户能够通过 GDB 的界面使用它。 #### 其他重要进展 * **Chronon (2010-2016)** : 面向Java的"DVR for Java"时间旅行调试器,最终被CA Technologies收购 * **UndoDB (2007至今)** : 商业Linux确定性重放调试器,特别在嵌入式领域有所应用 * **Microsoft TTD (2016至今)** : Windows Time Travel Debugging,集成到WinDbg中的确定性重放功能 * **Pernosco (2018至今)** : 由RR开发者创建的基于云的调试平台,进一步提升了确定性重放的可用性 ### 3. 分布式时代的确定性重放挑战 随着软件架构向分布式系统、微服务和云原生应用演进,确定性重放面临更大挑战: #### 主要难点 * **多节点协同** : 需要捕获和同步分布在多个物理机器上的事件 * **规模问题** : 系统规模扩大导致记录开销和数据量激增 * **异构环境** : 不同服务可能使用不同语言、框架和运行时环境 * **非确定性来源增多** : 网络延迟、负载均衡、服务发现等引入更多不确定性 #### 现有的部分解决方案 尽管全系统确定性重放仍然难以实现,但业界已发展出几种针对性方案: ##### 分布式追踪系统 * **Jaeger、Zipkin、OpenTelemetry** : 这些工具虽不提供完整的确定性重放,但通过分布式追踪提供系统行为的可观测性 * **Chrome DevTools Protocol** : 为前端应用提供时间旅行调试能力 ##### 事件溯源与CQRS * **事件溯源(Event Sourcing)** : 通过记录所有状态变更事件,实现系统状态的重建和回溯 * **命令查询责任分离(CQRS)** : 配合事件溯源,提供对系统状态历史的查询能力 ##### 隔离测试与服务虚拟化 * **服务存根(Service Stubbing)** : 模拟依赖服务行为,减少外部因素影响 * **请求记录与回放** : 记录特定服务的请求与响应,用于测试和调试 ##### 不完全确定性重放 * **Debugging Microservices (Netflix)** : 记录关键服务间交互而非完整状态 * **Jepsen和TLA+** : 形式化验证和混沌工程工具,帮助发现分布式系统中的问题 ### 4. 人工智能时代的确定性重放发展方向 AI时代为确定性重放带来新挑战也带来新机遇: #### AI增强的调试体验 * **智能根因分析** : 利用机器学习分析执行轨迹,自动识别异常模式和潜在根因 * **自然语言调试界面** : "为什么这个变量在第500步后变成了null?"等自然语言问题直接获得答案 * **异常预测** : 通过学习历史执行模式,预测可能出现的问题 #### 针对AI系统的确定性重放 * **神经网络执行的重放** : 记录大型模型推理过程中的关键决策点 * **训练过程重放** : 捕获模型训练中的关键节点状态,用于调试和理解 * **解释性增强** : 结合可解释AI技术,提供模型决策过程的可视化和重放 #### 混合方法与领域特定解决方案 * **领域特定语言(DSL)** : 为特定应用领域设计的确定性执行环境 * **可验证计算** : 结合形式化方法与确定性重放,提供更强的正确性保证 * **硬件辅助** : 利用新型处理器特性如Intel PT (Processor Trace)降低记录开销 #### 开放挑战与前沿探索 * **跨平台一致性** : 在异构环境中实现一致的重放体验 * **隐私保护下的重放** : 在记录敏感数据的同时保护用户隐私 * **规模化重放** : 为超大规模系统设计高效的记录与重放机制 * **量子计算环境** : 为本质非确定性的量子计算提供调试能力 ### 5. 总结与展望 确定性重放技术从学术概念到Mozilla RR等实用工具的发展,展示了软件工程面对复杂性挑战的演进过程。尽管在分布式和云原生环境中面临更多困难,确定性重放的核心思想——通过捕获和重现非确定性事件来实现可预测的调试体验——仍然具有重要价值。 随着AI技术的融入和硬件能力的提升,确定性重放很可能发展为更智能、更高效的调试范式。未来的解决方案将不再追求完美的全系统重放,而是关注特定领域的高价值应用,结合其他技术如可观测性、形式化验证和机器学习,共同提升软件质量和开发效率。 确定性重放技术告诉我们,有时候解决问题的最佳方式不是构建完美的全能工具,而是深入理解问题本质,针对最有价值的场景提供实用的解决方案。这一理念不仅适用于调试工具,也值得整个软件工程领域借鉴。 ================================================ FILE: book/10-extras/8-development-of-debug-dsys.md ================================================ ## 分布式系统调试的发展历程 ### 1. 分布式系统调试诞生的问题背景 随着计算机科学的发展,单机系统逐渐无法满足高并发、高可用、大规模数据处理的需求,分布式系统应运而生。分布式系统将计算任务分散到多台计算机上,通过网络协作完成复杂的业务逻辑,极大地提高了系统的性能和可靠性。然而,分布式系统的复杂性也带来了前所未有的调试挑战: * **状态分散性** :系统状态分散在多个节点上,难以获得全局一致的视图 * **时序不确定性** :分布式事件的发生顺序难以精确控制和复现 * **异步通信** :组件间的异步消息传递增加了调试复杂度 * **部分故障** :系统可能处于部分失效状态,某些节点故障而其他节点正常运行 * **环境依赖性** :问题可能只在特定环境或配置下出现 这些特性使得传统的调试方法(如单步执行、断点调试)在分布式系统中难以应用。开发者面临着如何理解、分析和修复分布式系统中的错误这一巨大挑战。 #### 传统调试方法的局限性 在单机系统时代,开发者可以依赖诸如GDB、Visual Studio Debugger等工具,通过设置断点、检查变量、单步执行代码等方式直观地跟踪程序执行流程。然而,这些工具在分布式环境中面临严重局限: 1. **无法捕获跨节点交互** :传统调试器无法捕获跨节点的消息传递和状态变化 2. **难以重现问题** :由于时序不确定性,同样的操作可能导致不同的结果 3. **调试过程影响系统行为** :调试器的介入可能改变系统的时序特性,导致"观察者效应" 4. **难以处理大规模数据** :分布式系统产生的日志和状态信息常常过于庞大,难以手动分析 这些挑战催生了专门针对分布式系统的调试解决方案的需求。 ### 2. 分布式系统调试方案的发展历程 #### 初期阶段:日志分析与追踪(1990年代-2000年代初) 在分布式系统调试的早期阶段,开发者主要依赖于日志分析。每个节点产生独立的日志文件,开发者通过手动分析和关联这些日志来理解系统行为。 **代表性技术与事件:** * **集中式日志系统** :如Syslog,允许将多个节点的日志集中到一个位置进行分析 * **日志分析工具** :如Splunk(2003年推出)提供了更强大的日志搜索和分析能力 * **分布式追踪的理论基础** :Google发表了Dapper论文(2010年),为后来的分布式追踪系统奠定了基础 **教训与挑战:** * **时间同步问题** :不同节点的时钟可能不同步,导致日志时间戳不准确,难以重建事件顺序 * **缺乏关联性** :难以将不同节点上的相关事件联系起来 * **分析效率低** :手动分析大量日志耗时且容易出错 #### 发展阶段:分布式追踪与监控(2000年代中期-2010年代中期) 随着分布式系统规模的扩大,单纯依靠日志分析变得越来越困难。开发者开始寻求更系统化的解决方案。 **代表性技术与事件:** * **分布式追踪系统** : * Zipkin(2012年,由Twitter开源) * Jaeger(2017年,由Uber开源) * X-Trace(2007年,UC Berkeley) * **监控系统的演进** : * Nagios(1999年) * Prometheus(2012年) * Grafana(2014年) * **APM工具** : * New Relic(2008年) * AppDynamics(2008年) * Dynatrace(1993年,但在2000年代后期重新定位为APM工具) **教训与挑战:** * **追踪数据爆炸** :随着系统规模增长,追踪数据量呈爆炸式增长,需要抽样或过滤 * **追踪对性能的影响** :全面追踪可能导致系统性能下降 * **缺乏事后调试能力** :这些系统主要用于监控和分析,而非实时调试 #### 突破阶段:交互式分布式调试(2010年代中期-2020年代初) 随着容器化和Kubernetes等技术的普及,出现了真正意义上的交互式分布式调试工具。 **代表性技术与事件:** * **SquashIO的Squash Debugger** (2017年):这是一个重要的里程碑,它允许开发者在Kubernetes集群中进行实时调试。Squash集成了传统调试器(如GDB、Delve)与Kubernetes环境,使开发者能够在分布式环境中设置断点和检查变量。 * **Telepresence** (2017年):允许开发者将本地开发环境与远程Kubernetes集群连接起来,在本地调试运行在集群中的服务。 * **Rookout** (2018年):提供了非侵入式的调试能力,允许在生产环境中实时收集调试数据,而无需重启应用或修改代码。 **教训与挑战:** * **平台特定性** :这些工具往往与特定平台(如Kubernetes)紧密耦合 * **语言依赖性** :部分工具只支持特定的编程语言 * **难以扩展到超大规模系统** :在有数千个微服务的环境中,定位问题仍然十分复杂 #### 惨痛教训:真实案例 **案例1:Knight Capital的灾难性部署(2012年)** Knight Capital是一家金融交易公司,在2012年8月1日,由于一个分布式系统部署错误,公司在45分钟内损失了4.6亿美元。问题的根源是软件部署不一致,导致活跃服务器使用了旧代码,而这些服务器又与使用新代码的服务器交互。由于缺乏有效的分布式调试工具,Knight Capital花了数小时才理解并修复问题,但为时已晚。 **案例2:亚马逊S3宕机事件(2017年)** 2017年2月28日,亚马逊S3服务发生了严重的宕机事件,影响了大量依赖AWS的网站和服务。问题的根源是一个看似简单的命令错误,但在分布式系统中产生了级联故障。尽管亚马逊拥有大量监控和调试工具,但系统的复杂性使得快速识别和解决问题变得极其困难。 ### 3. 微服务与云原生时代的分布式调试挑战 随着微服务架构和云原生技术的广泛采用,分布式系统调试面临新的挑战: * **服务数量激增** :一个企业级应用可能包含数百甚至数千个微服务 * **动态性增强** :容器的自动扩缩容、服务网格等技术使系统更加动态 * **多语言、多框架** :微服务可能使用不同的编程语言和框架实现 * **复杂的依赖关系** :服务之间的复杂依赖关系使得故障定位更加困难 #### 当前解决方案 **服务网格技术** : * **Istio** (2017年):提供了流量管理、安全性和可观察性功能 * **Linkerd** (2016年):专注于轻量级、简单的服务网格实现 * **Consul** (2014年):提供服务发现和配置管理功能 **可观察性三支柱** : * **日志(Logs)** :ELK/EFK Stack(Elasticsearch、Logstash/Fluentd、Kibana) * **指标(Metrics)** :Prometheus、Grafana等 * **追踪(Traces)** :OpenTelemetry(2019年)统一了追踪标准 **混沌工程** : * **Chaos Monkey** (2011年,Netflix):有意引入故障以测试系统弹性 * **Gremlin** (2016年):提供了更系统化的混沌工程平台 **生产环境调试** : * **Lightstep** (2015年):提供了深入的分布式追踪和分析能力 * **Honeycomb** (2016年):专注于可观察性和事件驱动的调试 虽然这些工具极大地提高了分布式系统的可观察性,但仍然缺乏真正的交互式调试能力。开发者需要组合使用多种工具,并且需要额外的专业知识来解释和分析获得的数据。 ### 4. 人工智能时代的分布式系统调试 随着人工智能技术的飞速发展,分布式系统调试也开始融入AI能力,开启了新的可能性。 #### 当前趋势与探索 **AIOps(人工智能运维)** : * **异常检测** :使用机器学习算法自动检测系统异常 * **根因分析** :通过因果推理分析故障根源 * **自动修复** :在某些情况下,系统可以自动生成修复方案 **代表性技术与平台** : * **Datadog的Watchdog** (2018年):使用AI检测系统异常 * **IBM的Watson AIOps** (2020年):应用AI技术进行问题诊断和解决 * **Microsoft的BugLab** (2021年):使用AI辅助bug定位和修复 #### 未来发展方向 **大型语言模型(LLM)辅助调试** : * **代码理解与分析** :LLM可以理解复杂的分布式系统代码和架构 * **日志分析与解释** :自动分析日志并提供人类可理解的解释 * **自动生成调试计划** :根据系统描述和问题现象,生成调试步骤 **自适应调试系统** : * **动态调整观察点** :根据系统行为自动调整数据收集点 * **智能采样** :在保证有效性的前提下,减少数据收集量 * **预测性调试** :在问题发生前预测可能的故障点 **数字孪生与模拟** : * **系统行为模拟** :通过数字孪生技术模拟分布式系统行为 * **假设验证** :在虚拟环境中测试修复方案 * **时间旅行调试** :在模拟环境中实现时间前后移动的调试能力 **自主式调试助手** : * **调试代理** :自主运行在分布式系统中,收集和分析数据 * **自动化工具链** :集成多种调试工具,形成闭环调试流程 * **持续学习** :通过历史调试数据不断改进调试能力 ### 5. 总结与展望 分布式系统调试从最初的简单日志分析,发展到今天的综合性解决方案,经历了多个阶段的演变。每个阶段都伴随着技术的突破和惨痛的教训。 #### 关键发展阶段回顾 1. **初期的日志分析阶段** :依靠简单工具,效率低下 2. **分布式追踪与监控阶段** :提高了系统的可观察性,但缺乏交互能力 3. **交互式调试阶段** :以Squash Debugger为代表,实现了真正的分布式调试 4. **微服务与云原生时代** :服务网格和可观察性工具的广泛应用 5. **人工智能辅助调试** :开始探索AI在分布式调试中的应用 #### 未来展望 分布式系统调试的未来将可能是一个全方位的综合平台,它将包含: * **AI驱动的根因分析** :自动分析系统行为,定位问题根源 * **自适应调试工具** :根据系统特性和问题类型自动选择合适的调试策略 * **无代码调试接口** :允许开发者通过自然语言描述进行调试 * **实时协作调试** :支持多人协作调试复杂系统 * **预防性调试** :在问题发生前预测并防范潜在问题 随着技术的不断进步,我们有理由相信,未来的分布式系统调试将变得更加智能、高效和用户友好。这将大大降低开发和维护分布式系统的成本,推动分布式计算技术的进一步发展。 ### 参考文献 1. [squash debugger](https://squash.solo.io/overview/), https://squash.solo.io/overview/ 2. [rookout debugger](https://www.rookout.com/solutions/live-debugger/), https://www.rookout.com/solutions/live-debugger/ 3. [telepresence](https://telepresence.io/), https://telepresence.io/ ================================================ FILE: book/10-extras/9-ai-assisted-debugging.md ================================================ ## Latest: AI Agent自动化调试 ### 思考:AI是否可以在调试领域有所作为 在软件开发过程中,调试是一项耗时且重复性高的工作。传统的调试方式通常是开发者手动与调试器交互:设置断点,运行到断点,检查变量状态,决定下一步动作 …… 重复上述过程。这种方式在定位复杂bug时往往需要进行数十次或数百次的交互,是典型的"体力活"。 随着大型语言模型(LLM)的发展,我们有机会将这类重复性工作自动化。AI Agent可以:理解程序逻辑和错误症状,根据当前调试状态推导下一步调试策略,通过ReAct(Reasoning + Acting)模式不断执行并根据结果调整下一步调试命令,从而自动定位问题所在。 这样做的优势包括: 1. **效率提升**:从手动数十次交互降低到自动多轮推理 2. **减少人工成本**:让专家从重复性工作中解放出来 3. **提高诊断准确性**:AI可以系统地遍历可能的状态空间 4. **支持复杂场景**:多线程、并发、分布式系统等复杂场景的调试,AI更能解放人工调试的局限性 ### 原理:AI自动化调试是否可以真实落地 #### 人工调试时与调试器的交互模式 通常,我们使用调试器时是通过交互式终端(Interactive Terminal): ``` 用户+交互式终端 ←→ 调试器进程 ←→ 被调试程序进程 ``` - **输入(stdin)**:用户在终端输入调试命令(break, step, print等) - **输出(stdout)**:调试器打印执行结果、变量信息、源代码视图等 - **实时反馈**:用户立即看到结果,基于结果调整下一步操作 #### AI自动化调试时与调试器的交互模式 当使用I/O重定向时,流程变为: ``` AI Agent ←→ stdin/stdout/stderr 文件 ←→ 调试器进程 ←→ 被调试程序进程 ``` **关键洞察**: 1. **行缓冲执行**:调试器每次从stdin读取一行命令,执行该命令,然后将结果写入stdout 2. **AI驱动**:AI Agent作为外部控制器,根据当前stdout内容决定下一条命令 3. **多轮交互**:通过不断读取stdin反馈、调用LLM推理、写入新命令到stdin,实现自动化调试 #### 具体可行的工作流程 ``` [和AI交互阶段] 你好,程序main中有个bug,你帮我定位下。你可以使用tinydbg对其进行调试,tinydbg支持输入输出重定向,具体参数可以查看 `tinydbg help`。 这个阶段,AI会通过工具调用了解tinydbg的使用方法、IO重定向的方法。当LLM确定了如何使用之后,就会推理出下一步要执行的 `tinydbg命令`, 以及接下来大致希望执行的调试动作,blablabla。 [初始化阶段] 1. 将调试命令序列写入stdin.txt 2. 启动调试器:tinydbg exec ./main --allow-non-terminal-interactive < stdin.txt > stdout.txt 2> stderr.txt 3. 调试器以行缓冲模式读取stdin,每次读取一个命令来执行 [执行阶段 - 每次迭代] 1. 调试器读入一条命令(如 "step") 2. 执行该命令,得到结果(源代码位置、变量值等) 3. 将结果写入stdout,如果报错将错误信息写入stderr 4. 调试器尝试读取下一条命令 [AI反馈循环] 1. AI Agent读取stdout的最新输出和stderr的最新错误信息 2. 通过ReAct推理判断: - 当前程序状态是什么? - 是否到达预期状态? - 下一步应该怎么做? 3. 追加新命令到stdin 4. 调试器继续读取命令并执行 5. 返回步骤1 [终止阶段] 1. 当AI判断bug已定位或问题已解决 2. 追加 "exit" 命令 3. 调试器退出,stdin/stdout/stderr保存完整的调试过程 ``` #### 为什么这种模式有效 1. **确定性**:每个调试命令的效果完全由当前程序状态决定,方便AI推理 2. **完全可观测**:所有调试输出(stdout/stderr)都被记录,便于事后分析 3. **灵活性**:AI可以在任何时刻基于最新输出调整策略 4. **可复现**:完整的stdin记录了调试过程,可以完全重放 5. **兼容性**:对调试器无特殊要求,只要支持IO重定向即可 ### 案例演示:Array Index Out Of Bounds #### 1. 问题代码 文件:`main.go` ```go package main import "fmt" func main() { a := []int{1, 2, 3, 4, 5} sum := 0 for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) sum += a[i] } fmt.Println("Sum:", sum) } ``` **问题**:循环条件为 `i <= len(a)` 而非 `i < len(a)`,导致当 `i == 5` 时访问 `a[5]`,超出数组边界。 **预期表现**:运行程序会触发panic: `runtime error: index out of range [5] with length 5` #### 2. 编译与调试准备 ```bash cd /home/zhangjie/debugger101/tinydbg/examples/llm_debug2 # 使用Go 1.24.6编译,禁用优化 go1.24.6 build -gcflags 'all=-N -l' -o main main.go # 验证编译成功 ls -la main ``` #### 3. AI驱动的调试过程 >这里的AI Agent可选项就很多了,比如cursor、vscode copilot甚至你自己写的AI Agent。这个读者根据自己的喜好进行选择就可以了。 LLM输出工具调用参数,驱动AI Agent执行调试器help命令(`tinydbg help` 和 `tinydbg help redirect`)来了解如何实现重定向。学习到下列命令:`tinydbg exec ./main --allow-non-terminal-interactive < stdin.txt > stdoutxxx.txt 2> stderrxxx.txt` 。接下来LLM就继续推理初步的调试命令,如先停在main.main入口,再一步步尝试其他调试命令及执行结果,来逼近最终问题。那么调试命令LLM如何输出的呢?其实就是调用WriteFile之类的toolcall将对应的调试命令写入stdin.txt。 下面是真实执行过程中的演示,您可以使用常见的AI Agent工具(如cursor、vscode copilot等进行测试)。 ##### 第1轮:设置断点,达到初始状态 **AI推理**:程序无法直接看到内部状态,需要使用调试器。首先应在 `main` 函数入口设置断点,然后运行到该断点。 **调试命令** (写入stdin.txt第1-2行): ``` break main.main continue ``` **执行结果** (stdout片段): ``` (tinydbg) Breakpoint 1 set at 0x4b1af3 for main.main() ./main.go:5 (tinydbg) > [Breakpoint 1] main.main() ./main.go:5 (hits goroutine(1):1 total:1) (PC: 0x4b1af3) 1: package main 2: 3: import "fmt" 4: => 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) 9: sum += a[i] 10: } ``` **AI观察**:程序已停在function entry point。现在应该检查初始变量。 ##### 第2轮:尝试打印变量(失败) **AI推理**:需要理解程序的初始状态。尝试打印数组 `a` 的当前值。 **调试命令** (第3行): ``` print a ``` **执行结果** (stderr片段): ``` Command failed: could not find symbol value for a ``` **AI学习**:当前执行位置虽然在 `main` 入口,但变量 `a` 尚未初始化。需要向前执行。 ##### 第3轮:逐步执行,初始化变量 **AI推理**:使用 `step` 命令逐行执行,让变量完成初始化。 **调试命令** (第4行): ``` step ``` **执行结果** (stdout片段): ``` (tinydbg) > main.main() ./main.go:6 (PC: 0x4b1afa) 5: func main() { 6: a := []int{1, 2, 3, 4, 5} ... ``` **AI观察**:现在停在第6行,即 `a` 的声明处。 ##### 第4轮:再次打印变量 **AI推理**:已经到达变量初始化语句。再执行一次 `step` 让赋值完成,然后打印。 **调试命令** (第5-7行): ``` step print a ``` **执行结果** (stdout片段): ``` (tinydbg) > main.main() ./main.go:7 (PC: 0x4b1b75) 7: sum := 0 ... (tinydbg) []int len: 5, cap: 5, [1,2,3,4,5] ``` **AI确认**:`a = [1,2,3,4,5]`,长度为5。 ##### 第5轮:进入循环,检查循环变量 **AI推理**:已理解初始状态:`a` 是长度为5的数组。现在需要进入循环,理解循环如何执行。首先应该到达循环的 `for` 语句。 **调试命令** (第8-10行): ``` step print i ``` **执行结果** (stdout片段): ``` (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1b7e) => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) 9: sum += a[i] (tinydbg) (tinydbg) > main.main() ./main.go:9 (PC: 0x4b1b9c) => 9: sum += a[i] (tinydbg) 0 ``` **AI确认**:`i = 0`,已进入循环体第一次迭代。 ##### 第6轮:循环遍历(多次迭代) **AI推理**:现在需要系统地观察循环如何进行。继续执行 `step` 和 `print i`,观察循环变量的递增。 **调试命令** (第11-28行,重复的 step/print i): ``` step print i step print i step print i step print i ...(共累计到i=4) ``` **执行结果** (stdout关键片段): ``` (tinydbg) 0 (tinydbg) 1 (tinydbg) 2 (tinydbg) 3 ``` **AI观察**: - 循环变量依次为:0, 1, 2, 3 - 这些值都在数组范围 `[0, 4]` 内(因为数组长度为5) - 循环条件为 `i <= len(a)`,意味着还会执行 `i = 5` 的情况 ##### 第7轮:触发异常 **AI推理**:根据循环条件 `i <= 5`,下一次循环应该会有 `i = 5` 的情况。这会导致访问 `a[5]`,超出数组界限。为了看到异常,应该执行 `continue`(继续执行直到异常或程序结束)。 **调试命令** (第29行): ``` continue ``` **执行结果** (stdout关键片段): ``` (tinydbg) > [unrecovered-panic] runtime.fatalpanic() /home/zhangjie/.goenv/sdk/go1.24.6/src/runtime/panic.go:1301 (hits goroutine(1):1 total:1) (PC: 0x43da84) Warning: debugging optimized function runtime.curg._panic.arg: interface {}(string) "runtime error: index out of range [5] with length 5" 1296: // fatalpanic implements an unrecoverable panic. It is like fatalthrow, except 1297: // that if msgs != nil, fatalpanic also prints panic messages and decrements 1298: // runningPanicDefers once main is blocked from exiting. ... =>1301: func fatalpanic(msgs *_panic) { ``` **AI诊断**: - 程序已捕获到panic异常 - 异常信息:`runtime error: index out of range [5] with length 5` - 这正好对应 `i = 5` 时访问 `a[5]` 的情况 - 根本原因:循环条件应该是 `i < len(a)` 而非 `i <= len(a)` ##### 第8轮:退出调试器 **AI结论**:Bug已定位,无需继续调试。 **调试命令** (第30行): ``` exit ``` ### 完整的调试文件内容 #### stdin.txt - 调试命令序列 ``` break main.main continue print a step print a step print a step print i step print i step print i step print i step print i step print i step print i step print i step print i continue print i exit ``` #### stdout.txt - 调试器输出(完整) ``` Type 'help' for list of commands. (tinydbg) Breakpoint 1 set at 0x4b1af3 for main.main() ./main.go:5 (tinydbg) > [Breakpoint 1] main.main() ./main.go:5 (hits goroutine(1):1 total:1) (PC: 0x4b1af3) 1: package main 2: 3: import "fmt" 4: => 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } (tinydbg) (tinydbg) > main.main() ./main.go:6 (PC: 0x4b1afa) 1: package main 2: 3: import "fmt" 4: 5: func main() { => 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) (tinydbg) (tinydbg) > main.main() ./main.go:7 (PC: 0x4b1b75) 2: 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} => 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) []int len: 5, cap: 5, [1,2,3,4,5] (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1b7e) 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) (tinydbg) > main.main() ./main.go:9 (PC: 0x4b1b9c) 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range => 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) 0 (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1bc8) 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) (tinydbg) > main.main() ./main.go:9 (PC: 0x4b1b9c) 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range => 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) 1 (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1bc8) 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) (tinydbg) > main.main() ./main.go:9 (PC: 0x4b1b9c) 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range => 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) 2 (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1bc8) 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) (tinydbg) > main.main() ./main.go:9 (PC: 0x4b1b9c) 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range => 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) 3 (tinydbg) > main.main() ./main.go:8 (PC: 0x4b1bc8) 3: import "fmt" 4: 5: func main() { 6: a := []int{1, 2, 3, 4, 5} 7: sum := 0 => 8: for i := 0; i <= len(a); i++ { // Bug: loop condition should be i < len(a) to avoid index out of range 9: sum += a[i] 10: } 11: fmt.Println("Sum:", sum) 12: } (tinydbg) (tinydbg) > [unrecovered-panic] runtime.fatalpanic() /home/zhangjie/.goenv/sdk/go1.24.6/src/runtime/panic.go:1301 (hits goroutine(1):1 total:1) (PC: 0x43da84) Warning: debugging optimized function runtime.curg._panic.arg: interface {}(string) "runtime error: index out of range [5] with length 5" 1296: // fatalpanic implements an unrecoverable panic. It is like fatalthrow, except 1297: // that if msgs != nil, fatalpanic also prints panic messages and decrements 1298: // runningPanicDefers once main is blocked from exiting. 1299: // 1300: //go:nosplit =>1301: func fatalpanic(msgs *_panic) { 1302: pc := sys.GetCallerPC() 1303: sp := sys.GetCallerSP() 1304: gp := getg() 1305: var docrash bool 1306: // Switch to the system stack to avoid any stack growth, which (tinydbg) exit ``` #### stderr.txt - 错误输出(关键失败案例) ``` Command failed: could not find symbol value for a Command failed: could not find symbol value for a Command failed: could not find symbol value for i Command failed: could not find symbol value for i Command failed: could not find symbol value for i Command failed: could not find symbol value for i Command failed: could not find symbol value for i ``` **说明**:前两条错误是因为在变量完全初始化前尝试打印。后续的错误是在循环条件判断时尝试打印尚未进入循环体的 `i`。如果读者感兴趣可以手动模拟下上述调试过程,感受下LLM+toolcall+调试器IO重定向基础上的自动化调试的威力。 ### 局限性和未来方向 #### 当前局限 1. **上下文窗口是第一瓶颈**:自动化调试依赖“持续观察 + 连续推理”,但真实工程里的调用链、变量快照、日志片段很容易超出模型可处理窗口,导致关键信息被截断或被稀释。 2. **调试轨迹长度持续增长**:随着toolcall轮数增多,历史命令与中间结论会不断堆积。如果没有结构化压缩机制,AI容易出现“记忆漂移”,重复已做过的尝试或忽略早期关键线索。 3. **长期记忆能力仍在演进**:当前很多Agent更多依赖短期上下文,而不是稳定、可检索、可更新的长期memory。跨会话、跨版本、跨故障类型复用经验的能力还有提升空间。 4. **信息组织方式影响推理上限**:不是只有模型参数决定效果,调试证据如何分层(现象、路径、假设、验证)同样关键。若上下文组织混乱,即使模型更强也可能无法稳定收敛。 5. **其他问题可能存在但可工程化处理**:例如工具调用预算、观测信号噪声、环境差异等,在部分场景中可能带来额外挑战,但总体属于可通过工程策略逐步优化的问题。 #### 未来方向 1. **上下文压缩与分层记忆**:将长链路调试信息压缩为结构化摘要(症状、证据、假设、结论),并通过可检索memory实现“短上下文推理 + 长记忆补全”。 2. **更大上下文窗口模型落地**:随着百万级甚至2M上下文窗口模型逐步成熟,AI一次可处理的调试信息规模将显著提升,长链路分析能力有望变得更稳定。 3. **动态记忆更新机制**:让Agent在调试过程中持续写入、修正、淘汰记忆项,避免“旧结论污染新推理”,提高多轮决策一致性。 4. **证据驱动的推理框架**:将日志、变量、调用栈、断点结果统一为证据图,按可信度驱动下一步动作,减少盲目试探和重复探索。 5. **time-travel debugging**: 通过类似 `rr` 的工具,可以逆向调试,某些情景下可以缓解多次重启调试会话对上下文窗口大小的侵蚀。 6. **模型进化与工程系统共振**:未来突破不只来自更强模型,也来自更好的Agent框架、工具协议与可观测性基础设施协同,自动化调试的上限会持续上移。 ### 本节小结 本节主要探讨了AI Agent自动化调试的可行性与实践路径,核心内容包括:利用stdin/stdout重定向把调试器交互转化为可编排的自动化流程;通过真实案例演示“推理-执行-反馈”的闭环定位方法;围绕当前AI阶段性瓶颈,重点分析上下文窗口、长链路记忆与信息组织对调试效果的影响。整体上,自动化调试的愿景清晰且可落地,当前问题更多是能力边界与工程细节问题,而非方向性障碍。随着上下文压缩、memory机制和大上下文模型持续演进,AI自动化调试有望从“可用”走向“稳定好用”。 ================================================ FILE: book/10-extras/README.md ================================================ ## 软件调试技术全景:工具与场景的精准适配 ### 引言 软件调试(Debugging)是贯穿软件生命周期的重要活动,其目标可能是一个进程、一个核心转储文件、带有不同优化特性的复杂程序,一个单体服务,也可能是一个分布式系统。**调试的本质是系统性认知与场景化工具的结合**。当开发者抱怨 “某个工具没用” 时,往往是因为他尚未遭遇需要该工具特性的复杂场景。本文将通过核心调试技术及其适用场景的解析,展示现代软件调试的立体图景。 ### 核心调试技术矩阵 #### 1. 调试器(Debugger) 调试器设计实现是全书介绍的目标,大家读到这里自然更不陌生了。调试器是通过断点控制、内存分析、堆栈跟踪等机制实现对目标进程状态观测的交互式工具。调试器对于单进程、多进程、多线程应用程序的实时状态分析中,作用非常大。现代调试器如go-delve/delve甚至实现了协程级的调试能力。对于操作系统内核的调试一般需要内核级调试器,如Linux kdb,Windows winDBG。对于编译期优化后的代码,借助DWARF也可以进行调试,比如内联函数。 > 前面提到过调试器也从一个简单的3层架构,演化到前后端分离式架构,以应对不同软硬件架构的差异。一些主流的IDE为了能与不同的调试器backend进行集成,也需要调试协议层面的标准化支持,如DAP(Debugger Adapter Protocol)。 > > 调试器对单进程、少量进程的程序调试还算比较简单,微服务架构下的分布式系统调试是一个挑战。 #### 2. 日志系统(Logging) 打日志也是一种非常普遍的调试手段,只要有源码,不管是本地运行的命令行程序,还是远程运行的服务,在可疑位置加几行代码,重新部署运行,就可以观察验证。打日志虽然适用面广,但并不总是那么高效。比如可能需要多次修改源码、编译构建、部署测试才能缩小问题范围。在某些代码交付、制品管理比较严格的企业,还需要经过一系列的评审、CI/CD流程检查。对于分布式系统,一般需要借助远程日志系统,并通过TraceID来串联完整的事务处理日志。为了方便检索,还可能需要结构化日志解析、存储、检索能力的支持,比如ELK Stack。 > 日志就是些不断追加的文本,如何从中提取有价值的信息? > > 在远程日志系统出现之前, `tail -f | grep` 或者 `grep ` 就是大家最常用的操作了吧,如果日志很多还需要 `ls -rth` 查看下日志最后修改时间以确定日志落在哪个文件。远程日志系统出现之后,我们需要将日志进行采集、清洗、解析,比如提取出traceid, loglevel, timestamp, event message 以及其他参数信息,上报到远程日志,远程日志基于这些构建一些索引方便我们进行检索。 > > 除了上述提及的流程方面的问题,远程日志系统有个不便之处,就是日志量大的情况下、等待日志入库、能被检索出来,一般都有分钟级延迟,对于希望高效调试的情景可能非常不便。 #### 3. 指标监控(Metrics) 监控打点,是一个软件工程师的必修课。有些新手只知道在出错的时候加个监控上报,但是老手却会在每个接口的总请求量、成功量、失败量加监控上报,处理逻辑进入一些关键分支、异常分支也会加上报,对于整体逻辑的处理耗时、其中关键步骤的耗时也会加监控。为什么?就是因为老司机们知道解决线上问题的急迫性,以及如何更好地定位问题。监控打点如果加的细致,也可以作为分析代码执行路径的一种根据,至少可以知道大盘的一个情况。再加上对代码的熟悉程度,也能比较容易缩小问题范围。 > 如CPU利用率、内存泄露趋势图会让开发者迅速联想到耗CPU、耗内存的部分代码,再比如事务处理耗时分布可以帮助联想到某些处理步骤。业务侧指标上报一般借助框架提供的操作,平台侧指标监控一般来源于平台可观测性能力的建设,如机器(物理机、虚拟机、容器)的网络、文件系统、任务调度、CPU、内存等情况,最近几年eBPF在这方面非常出彩。 > > 借助监控指标缩小问题范围后,还是需要再借助源码、其他手段进一步确定根因,在确定根因之前,监控指标也只是现象。 #### 4. 追踪系统(Tracing) 在微服务架构下的分布式系统领域,如何跟踪事务处理的全流程,是一个挑战。[Google dapper](https://static.googleusercontent.com/media/research.google.com/en//archive/papers/dapper-2010-1.pdf) 详细介绍了如何化解这些难题,如事务处理中调用了哪些服务、调用顺序、各自耗时、各自成功与否、请求及响应参数、关联事件信息等。该论文公开后,市面上出现了zipkin、jaeger等众多开源产品。 其实大家Chrome Developer Tools的Timing Tab也能看到类似的网络请求的tracing可视化信息,区别是dapper里的每个span展示的往往是微服务粒度的,而Timing Tab展示的是每个关键步骤的信息,如Queueing、Request Sent、Wait for response、Content Download。go tool trace也借鉴了这里的思路,将整个go runtime的执行情况都纳入了tracing分析之中,而且还提供了API允许开发者创建自己关心的tracing。 > 早期的opentracing往往聚焦在tracing领域,和metrics、logging的结合比较少,这意味着当你看到一个耗时比较久的span时,如果缺少日志系统的支持,你可能还是不知道当时的问题详情是什么,缺少日志嘛。如果没有关联metrics,你也不知道特定的某个请求触发了什么监控打点。 > > 这就是早期opentracing、opencencus等存在的不足,现在opentelemetry意识到了这点,将logging、metrics、tracing整合到了一起,形成了新的行业的可观测性标准。[opentelemetry](https://opentelemetry.io/) 可以在框架层实现,也可以借助eBPF的强大能力作为平台化能力来实现。 #### 5. 二分定位法 ##### 二分搜索 二分查找,特别适用于在有序数组中寻找目标元素,时间复杂度为 O(log n)。通过二分不断缩小搜索范围,直到找到目标或确定目标不存在。大家有算法基础,对此应该不陌生,这里我们想谈谈二分思想在bug定位方面的实践。 ##### git bisect 借助git bisect寻找引入bug的commit,`git bisect (good|bad) [...]`。假定我们发现当前版本存在bug,bad=HEAD,但是并不是当前版本首次引入的,但是我们凭印象记得v1.0是正常的,那么引入bug的commit肯定介于v1.0和当前最新版本之间。git提交历史中的commits都是按照时间顺序有序排列的,意味着可以采用二分查找的方式每次取一个commit,然后测试当前commit是否有bug,然后通过 `git bisect good|bad` 将比较结果反馈给git,辅助git确定下次的查找范围。附录[《Appendix: 使用 git bisect 定位引入bug的commit》](../12-appendix/3-git-bisect.md)提供了一个示例。 `git bisect` 可以在git commit粒度下锁定bug,但是这对于大型项目还不够。思考下面几个问题:1)程序中不止有一个bug;2)程序中的bug是在某几个特性共同开启的情况下才会出现;3)这些特性的代码分散在多个commit、多个源文件位置。这种情况下使用 `git bisect` 确定导致bug的源文件位置的最小集合比较困难,尤其是在具有一定规模的项目中,如何能解决这类问题呢? ##### bisect reduce Russ Cox等人提出了一种方法,用于快速定位go编译器、go运行时中bug位置的方法,[Hash-Based Bisect Debugging in Compilers and Runtimes](https://research.swtch.com/bisect),在这之前也有其他技术人员提出了类似的技术,比如 List-Based Bisect-Reduce,Counter-Based Bisect-Reduce,Russ Cox等人是在他们基础上提出了 Hash-Based Bisect-Reduce。区别就是使用hash值来唯一标识每个特性相关的代码(Hash("feat1"),或者特定源文件位置(Hash("file:lineno")),而不是使用冗长的locspec列表,或者对应位置的计数器(同一位置计数编号随着代码修改会失效)。 bisect reduce,大致思想就是我们要采用 “特性开关” 的实践方式,当然这里不完全等同于特性开关,也可以是一个简单的优化changelist(如同一个feat对应的多个源文件位置、特定的源码行) …… 我们会给changelist一个名字,比如MyChangeList。假设我们使用 [go bisect](https://github.com/golang/tools/cmd/bisect) 并使用对应的 [golang/tools/internal/bisect库](https://github.com/golang/tools/tree/master/internal/bisect) 来控制changelist开启关闭、上报,然后执行程序 `MyChangeList=y ./a.out` 就等同于打开该changelist所有源码位置,`MyChangeList=n ./a.out` 就等同于关闭该changelist所有源码位置。预期关闭changelist时没有bug、开启时有bug,此时结合上报可以收集到该changelist涉及到的所有源码位置,然后在此基础上进行基于二分的缩减(bisect-based reduction)。 大致思路是:先打开一半位置(记为集合a)检查有没有预期的bug,如果没有就再添加额外一半位置(记为集合b),如果有bug就将刚才添加的b缩减一半(记为c),如果减少后发现没没bug了,那可以确定刚才新添加的一半位置(差集b-c)会导致该预期的bug。将这些可疑位置(b-c)固定下来并在后续搜索过程中带上它们,接下来继续搜索a中可能的位置 …… 最终可以确定一个导致bug出现的源文件位置的局部最小集合,只要这些位置都被打开就会导致该预期的bug。详细的算法可以参考 https://research.swtch.com/bisect。 这里提供了一个demo供大家学习如何在go项目中使用bisect [bisect example](https://github.com/hitzhangjie/golang-debugger-lessons/tree/master/1000_hash_based_bisect_reduce)。 > ps: bisect reduce,和二分搜索都是基于分治或者二分的思想,但也不完全一样,这个场景下核心算法如果使用二分搜索是不正确的。 #### 6. 动态跟踪 eBPF(扩展Berkeley Packet Filter)是一项强大的技术,允许用户在不修改、不重启内核或明显降低系统性能的情况下安全地注入和执行自定义代码。它主要用于网络、性能分析和监控等领域。这里重点强调下eBPF在动态跟踪技术中的应用,Linux kprobe、uprobe、tracepoint 现在已经支持回调eBPF程序,借此可以实现非常强大的动态跟踪功能,比如bpftrace。 对于go语言调试而言,结合eBPF可以实现任意源文件位置的动态跟踪,只要工具实现的足够细致。作者现在维护了一个go程序动态跟踪工具[go-ftrace](https://github.com/hitzhangjie/go-ftrace),基于DWARF调试信息识别特定函数位置,并动态添加uprobe,然后注册eBPF耗时统计程序,这样就实现了强大的函数调用跟踪能力。 ```bash $ sudo ftrace -u 'main.*' -u 'fmt.Print*' ./main 'main.(*Student).String(s.name=(*+0(%ax)):c64, s.name.len=(+8(%ax)):s64, s.age=(+16(%ax)):s64)' ... 23 17:11:00.0890 main.doSomething() { main.main+15 ~/github/go-ftrace/examples/main.go:10 23 17:11:00.0890 main.add() { main.doSomething+37 ~/github/go-ftrace/examples/main.go:15 23 17:11:00.0890 main.add1() { main.add+149 ~/github/go-ftrace/examples/main.go:27 23 17:11:00.0890 main.add3() { main.add1+149 ~/github/go-ftrace/examples/main.go:40 23 17:11:00.0890 000.0000 } main.add3+148 ~/github/go-ftrace/examples/main.go:46 23 17:11:00.0890 000.0000 } main.add1+154 ~/github/go-ftrace/examples/main.go:33 23 17:11:00.0890 000.0001 } main.add+154 ~/github/go-ftrace/examples/main.go:27 23 17:11:00.0890 main.minus() { main.doSomething+52 ~/github/go-ftrace/examples/main.go:16 23 17:11:00.0890 000.0000 } main.minus+3 ~/github/go-ftrace/examples/main.go:51 23 17:11:00.0891 main.(*Student).String(s.name=zhang You record a failure once, then debug the recording, deterministically, as many times as you want. The same execution is replayed every time. > > 你只要能录制一次失败,就能利用这次录制进行无限制地重放,进而进行确定性地调试。 明星项目 **Mozilla RR** 做到了这一点,它记录了程序非确定性执行时的全量上下文信息,以使得后续基于录制文件的调试能够精准重放当时的状态,进而进行确定性地调试。rr 还支持了逆向调试,比如 gdb、dlv 的逆向调试命令,在使用 Mozilla RR 作为调试器backend的情况下就可以实现逆向调试,这是非常有用的,不必因为错过执行语句而重启整个调试过程。 读者可能很好奇,rr 录制全量上下文信息,指的是录制了什么呢?系统调用结果、收到的信号、线程创建和销毁、线程调度顺序、共享内存访问、时钟和计数器、硬件中断、随机性来源、内存分配情况,等等。究竟如何解决这些问题,详见论文:[Engineering Record And Replay For Deployability: Extended Technical Report](https://arxiv.org/pdf/1705.05937)。在记录了这些信息的基础上,我们就可以在调试期间通过tracer做些文章实现状态的精准重放,这样就解决了那些会导致flaky test的可变因素干扰的问题。 > ps: 录制数据如何精准重放呢?读者可以先联想下本书介绍过的ptrace对tracee的一系列控制,具体如控制tracee执行多少条指令停止,读写寄存器信息等,看看有没有什么思路。完整解决方案可以查看 Mozilla RR 的论文。 #### 8. 分布式系统调试 在系统架构设计领域,微服务架构越来越获得大家的青睐,独立部署、技术多样性、可扩展性、故障隔离、团队自治、模块化设计,等等都是它的一些优势。但是它确实也带来一些挑战,所以业界也针对性的出现了微服务治理方面的一些解决方案,比如CNCF中的一系列明星项目。这里我们重点提一下对于软件调试带来的挑战。 微服务架构下,由于一个完整的事务处理会在多个微服务中进行处理,给调试带来了非常多的麻烦: - 首先,整个系统的运行,依赖所有微服务的正确部署,可能涉及到很多机器,不一定支持混部,不一定能保证每个开发都有自己的自测环境; - 如果没有专属的自测环境,传统的调试器attach一个进程进行跟踪的方式,还会影响服务正常运行,影响其他人测试; - 即使有专属的自测环境,如果不能混部,还需要分别登录多台机器attach目标进程进行跟踪; - 即使有专属的自测环境,也能混部,attach多个进程、加断点的位置和时机也还是很难协调; - …… 总而言之,如果用调试器的思路去尝试解决这个问题,是真的比较难。我们一般会通过Logging、Metrics、Tracing系统来解决微服务架构下的这类问题,生产环境下实践看来也还不错。但是你要说这种方案完美,那也很不现实,比如开发、测试环境中,我们更希望快速定位问题,但是实际情况是:1)你可能要等待一段时间才能观察到日志、监控上报、链路跟踪信息,有一定延迟。2)而且你可能要反复修改代码(补充下日志、监控、创建新的trace或者span),编译,构建,部署测试 …… 然后才能观察到。 本来调试器可能只需要几秒钟就能搞定的事情,只是因为对多机多个进程attach、添加断点时机这块比较难协调,难道就认为调试器搞不定这种情景了?SquashIO提供了完整的云原生情景下的解决方案:Squash Debugger,支持Kubernetes、Openshift、Istio等容器编排平台,实时注入调试容器,并自动关联对应版本的源码,并且能在RPC调用触发后自动触发对Callee特定接口处理函数的断点设置、UI上也支持自动切换到目标服务,支持常见的VSCode等IDE。 ### 调试技术选择哲学 从单体应用到云原生、分布式系统,调试技术已形成多维武器库,形成了针对不同场景的技术矩阵。即将展开的系列文章将深入解析每项技术的实现原理、最佳实践和前沿发展,帮助开发者建立"场景-工具-方法论"的立体化调试思维。调试不仅是解决问题的过程,更是理解系统本质的认知革命。掌握不同场景的调试技术,开发者如同获得了上帝之眼,可以了解系统全貌,也可以拨开迷雾探查一切。 ### 参考文献 1. [Hash-Based Bisect Debugging in Compilers and Runtimes](https://research.swtch.com/bisect) 2. [go bisect tool](https://github.com/golang/tools/tree/master/cmd/bisect) 3. [go bisect library](https://github.com/golang/tools/tree/master/internal/bisect) 4. [Engineering Record And Replay For Deployability: Extended Technical Report](https://arxiv.org/pdf/1705.05937) 5. [Squash Debugger Docs](https://squash.solo.io/) 6. [Squash Debugger GitHub](https://github.com/solo-io/squash) 7. [Lightning Talk: Debugging microservices applications with Envoy + Squash - Idit Levine, Solo.io](https://www.youtube.com/watch?v=i5_eacXkw3w) 8. [Dapper, a Large-Scale Distributed System Tracing Infrastructure](https://static.googleusercontent.com/media/research.google.com/en//archive/papers/dapper-2010-1.pdf) 9. [OpenTelemetry](https://opentelemetry.io/) ================================================ FILE: book/11-thanks/README.md ================================================ ## 致谢 一路走来,收获过成功,也遭遇过失败,感谢父母一直做我坚强的后盾,即便在未知面前,他们的鼓励也总能给我无穷的信心,让我能够走出迷雾。 感谢我的妻子,容忍我在家庭生活中的偷懒,容忍我无数次放鸽子来写这些东西。没有她的包容、理解、照顾、鼓励,我可能也不会坚持到现在。 也感谢朋友、同事、其他贡献者的支持、鼓励。 最后,我也要感谢自己,感谢自己对价值的探索、追求和坚持。 ================================================ FILE: book/12-appendix/1-go-programme-start.md ================================================ # go runtime: go程序启动流程 ## go程序启动流程概览 我们使用如下源程序作为示例,来看一看go程序的启动过程: **file: main.go** ```go package main import "fmt" func main() { fmt.Println("vim-go") } ``` 运行dlv进行调试,将程序执行到main.main处: ``` $ dlv debug main.go Type 'help' for list of commands. (dlv) b main.main Breakpoint 1 set at 0x10d0faf for main.main() ./main.go:5 (dlv) c > main.main() ./main.go:5 (hits goroutine(1):1 total:1) (PC: 0x10d0faf) 1: package main 2: 3: import "fmt" 4: => 5: func main() { 6: fmt.Println("vim-go") 7: } (dlv) ``` 这个时候看一下调用堆栈: ```bash (dlv) bt 0 0x00000000010d0faf in main.main at ./main.go:5 1 0x000000000103aacf in runtime.main at /usr/local/go/src/runtime/proc.go:204 2 0x000000000106d021 in runtime.goexit at /usr/local/go/src/runtime/asm_amd64.s:1374 (dlv) ``` 由此可知go程序启动是按照如下流程启动的: 1. asm_amd64.s:1374 runtime·goexit:runtime·goexit1(SB) 2. runtime/proc.go:204 runtime.main:fn() 这里的fn就是测试源程序中的main.main 3. 现在PC就停在main.main处,等待我们进行后续调试。 ## go程序启动前初始化 这里我们讲的启动前初始化,指的是程序执行到我们的入口函数main.main之前的操作,理解这部分内容,将有助于建立对go的全局认识,也有助于加强对实现go调试器的认识。 ### go进程实例化 当我们在shell里面键入`./prog`时,操作系统为我们实例化了一个prog程序的实例,进程启动了,这个过程中发生了什么呢? - shell中首先fork一个子进程,就称为子shell吧; - 子shell中再通过执行execvp替换掉进程待执行程序的代码、数据等等; - 一切准备就绪后,操作系统将准备好的进程状态交给调度器调度执行; 我们就假定当前调度器选中了当前进程,看下go进程从启动开始执行了什么逻辑。 在编译c程序的时候,我们知道一个源程序首先会被编译成*.o文件,然后同系统提供的共享库、系统提供的启动代码结合起来进行链接(link)之后,形成一个最终的可执行程序。链接的时候有internal linkage(静态链接)或者external linkage(动态链接)两种方式。 go程序和c程序类似,也有不同的链接方式,参考`go tool link`中的`-linkmode`选项说明进行了解。通常情况下如果没有cgo,默认go build构建出来的都是internal linkage,所以其体积也稍大,通过系统工具`ldd `查看依赖的共享库会提示错误`not dynamic executable`也可以证实这点。 ### go进程启动代码 go程序对应的进程开始执行之后,其首先要执行的指令就是启动代码,如下所示: **file: asm_amd64.s** ```asm // _rt0_amd64 is common startup code for most amd64 systems when using // internal linking. This is the entry point for the program from the // kernel for an ordinary -buildmode=exe program. The stack holds the // number of arguments and the C-style argv. TEXT _rt0_amd64(SB),NOSPLIT,$-8 MOVQ 0(SP), DI // argc LEAQ 8(SP), SI // argv JMP runtime·rt0_go(SB) // main is common startup code for most amd64 systems when using // external linking. The C startup code will call the symbol "main" // passing argc and argv in the usual C ABI registers DI and SI. TEXT main(SB),NOSPLIT,$-8 JMP runtime·rt0_go(SB) ``` 上述是go程序构建时分别采用internal、external linkage时使用的启动代码,go进程启动时将首先执行这段指令。第一种是首先为进程传递参数argc、argv,然后跳到`runtime.rt0_go(SB)`执行,第二种是说c启动代码在调用main之前会负责传递argc、argv,`runtime.rt0_go(SB)`。 就先不在linkmode对启动代码的影响这多做讨论了,直接看`runtime.rt0_go(SB)`。 ### `runtime.rt0_go(SB)` 这里汇编代码篇幅过长,我们省去了大部分汇编代码,只保留了比较重要的步骤的说明。 ```asm TEXT runtime·rt0_go(SB),NOSPLIT,$0 // copy arguments forward on an even stack ... // create istack out of the given (operating system) stack. ... // find out information about the processor we're on ... // others ... ok: // set the per-goroutine and per-mach "registers" ... // save m->g0 = g0 ... // save m0 to g0->m ... // copy argc ... // copy argv ... CALL runtime·args(SB) CALL runtime·osinit(SB) CALL runtime·schedinit(SB) // create a new goroutine to start program MOVQ $runtime·mainPC(SB), AX // entry PUSHQ AX PUSHQ $0 // arg size CALL runtime·newproc(SB) POPQ AX POPQ AX // start this M CALL runtime·mstart(SB) CALL runtime·abort(SB) // mstart should never return RET // Prevent dead-code elimination of debugCallV1, which is // intended to be called by debuggers. MOVQ $runtime·debugCallV1(SB), AX RET ``` 我们看到在完成上半部分的一些初始化之后,还做了这些操作: 1. copy argc, copy argv 2. call runtime·args(SB), call runtime·osinit(SB), call runtime·schedinit(SB) 3. create a new goroutine to start program 1. push entry: $runtime·mainPC(SB) 2. push arg size: $0 3. call runtime·newproc(SB) 4. call runtime·mstart(SB) 这些步骤就是我们关心的go程序启动的关键部分了,不妨一一来看下。 > ps:阅读go汇编,需要先阅读下相关的基础知识,可以参考下 [a quick guide to go's assembler](https://golang.org/doc/asm). > > - `FP`: Frame pointer: arguments and locals. > - `PC`: Program counter: jumps and branches. > - `SB`: Static base pointer: global symbols. > - `SP`: Stack pointer: top of stack. > > All user-defined symbols are written as offsets to the pseudo-registers `FP` (arguments and locals) and `SB` (globals). > > The `SB` pseudo-register can be thought of as the origin of memory, so the symbol `foo(SB)` is the name `foo` as an address in memory. This form is used to name global functions and data. Adding `<>` to the name, as in `foo<>(SB)`, makes the name visible only in the current source file, like a top-level `static` declaration in a C file. Adding an offset to the name refers to that offset from the symbol's address, so `foo+4(SB)` is four bytes past the start of `foo`. #### call runtime·args(SB) 指的是runtime package下的args这个函数,总之就是设置argc、argv这些参数的。 **file: runtime/runtime1.go** ```go func args(c int32, v **byte) { argc = c argv = v sysargs(c, v) } ``` #### runtime·osinit(SB) 指的是runtime package下的osinit这个函数,总之就是写系统设置相关的,先不关心。 **file: runtime/os_linux.go** ```go func osinit() { ncpu = getproccount() physHugePageSize = getHugePageSize() osArchInit() } ``` #### call runtime·schedinit(SB) 指的是runtime package下的schedinit这个函数,做了一些调度执行前的准备。 ```go // The bootstrap sequence is: // // call osinit // call schedinit // make & queue new G // call runtime·mstart // // The new G calls runtime·main. func schedinit() { // lockInit Linux下为空操作 ... // raceinit must be the first call to race detector. // In particular, it must be done before mallocinit below calls racemapshadow. // @see https://github.com/golang/go/blob/master/src/runtime/HACKING.md // 参考对getg()的解释:这里应该是在系统栈上运行,返回的_g_应该是当前M的g0 _g_ := getg() if raceenabled { _g_.racectx, raceprocctx0 = raceinit() } sched.maxmcount = 10000 moduledataverify() stackinit() mallocinit() fastrandinit() // must run before mcommoninit mcommoninit(_g_.m, -1) cpuinit() // must run before alginit alginit() // maps must not be used before this call modulesinit() // provides activeModules typelinksinit() // uses maps, activeModules itabsinit() // uses activeModules msigsave(_g_.m) initSigmask = _g_.m.sigmask goargs() goenvs() parsedebugvars() gcinit() lock(&sched.lock) sched.lastpoll = uint64(nanotime()) procs := ncpu if n, ok := atoi32(gogetenv("GOMAXPROCS")); ok && n > 0 { procs = n } procresize(procs) ... unlock(&sched.lock) ... } ``` #### 启动runtime.main & main.main 好了,上面一大堆都是一些初始化的工作,现在看下runtime.main启动的最直接部分: ```asm // create a new goroutine to start program MOVQ $runtime·mainPC(SB), AX // entry PUSHQ AX PUSHQ $0 // arg size CALL runtime·newproc(SB) POPQ AX POPQ AX // start this M CALL runtime·mstart(SB) ``` 这里首先首先获取符号`$runtime.mainPC(SB)`的地址放入AX,这个其实是函数runtime.main的入口地址,然后压函数调用参数argsize 0,因为这个函数没有参数。 ```asm DATA runtime·mainPC+0(SB)/8,$runtime·main(SB) GLOBL runtime·mainPC(SB),RODATA,$8 ``` runtime·main(SB)对应的就是runtime.main这个函数: ```go // The main goroutine. func main() { g := getg() // Racectx of m0->g0 is used only as the parent of the main goroutine. // It must not be used for anything else. g.m.g0.racectx = 0 // 调整协程栈大小,64位最大1GB,32位最大250M ... // Allow newproc to start new Ms. mainStarted = true if GOARCH != "wasm" { // no threads on wasm yet, so no sysmon systemstack(func() { // 创建新的m,并执行sysmon,-1表示不预先指定m的id newm(sysmon, nil, -1) }) } // 注意,现在执行的是main goroutine,当前线程是主线程, // 调用该方法将是的main goroutine绑定调度线程到主线程, // 意味着我们可以断定,main.main这个函数永远运行在主线程之上,除非之后解绑 lockOSThread() ... // 这里就是执行runtime package下的初始化逻辑: // - 每个package都有一些import进来的依赖,这些import的package需要做初始化逻辑; // - 每个package内部的func init()需要在初始化完依赖之后完成调用; doInit(&runtime_inittask) // must be before defer ... // Defer unlock so that runtime.Goexit during init does the unlock too. needUnlock := true defer func() { if needUnlock { unlockOSThread() } }() ... // 在调用用户编写的程序代码之前,开启gc,这里并没有创建独立线程来做gc,可能以后会 gcenable() main_init_done = make(chan bool) if iscgo { ... // Start the template thread in case we enter Go from // a C-created thread and need to create a new thread. startTemplateThread() cgocall(_cgo_notify_runtime_init_done, nil) } // 初始化main package,包括其import的依赖,以及main package下的func init() doInit(&main_inittask) // main package初始化完成 close(main_init_done) needUnlock = false // 注意,此处又将当前goroutine与thread做了分离,看来go的设计者只是想 // 将某些初始化动作放在main thread上完成,并不想事后仍然特殊对待main goroutine, // main goroutine和其他goroutine一样,可以由scheduler选择其他线程对其进行调度 unlockOSThread() // 如果编译成的是静态库、动态库,虽然有main函数,但是不能执行 if isarchive || islibrary { return } // 注意,调用main_main,其实就是main.main,请查看前面的go directive定义: // 就是//go:linkname main_main main.main,对main_main的调用将转入main.main // // 因为前面已经解绑了main goroutine和main thread的关系,所以我们唯一可以断定的, // 是main.main方法是执行在main goroutine上的,但是不一定在main thread上 fn := main_main fn() if raceenabled { racefini() } // main.main结束,意味着整个程序准备结束, // 如果有panic发生,会通知所有协程打印堆栈 if atomic.Load(&runningPanicDefers) != 0 { // Running deferred functions should not take long. for c := 0; c < 1000; c++ { if atomic.Load(&runningPanicDefers) == 0 { break } Gosched() } } if atomic.Load(&panicking) != 0 { gopark(nil, nil, waitReasonPanicWait, traceEvGoStop, 1) } exit(0) ... } ``` 这里我们分析了go程序启动的一个流程,以及我们可以得出的一个非常重要的结论: > main.main方法是由main goroutine来执行,但是main goroutine不一定由main thread来调度执行。 > > main goroutine和main thread二者之间没有默认的绑定关系! 明确这点是非常重要的,它将有助于我们理解`godbg attach `之后为什么main方法没有停下来的问题。 ================================================ FILE: book/12-appendix/2-auto-trace-cloned-threads.md ================================================ ## Appendix: trace newly cloned threads ### how does tracer automatically trace newly cloned threads PTRACE_O_TRACECLONE is an option for the ptrace system call that allows a tracer process to receive notifications when a traced process clones new child processes via fork() or clone(). Here is how PTRACE_O_TRACECLONE works: - A tracer process calls ptrace() on the process to be traced, passing the PTRACE_O_TRACECLONE option. - This sets up the traced process to notify the tracer when it clones new child processes. - When the traced process calls fork()/clone(), the kernel will pause the new child process before it starts executing. - The kernel notifies the tracer process by delivering a PTRACE_EVENT_CLONE event along with information about the new child process (pid, registers, etc). - The tracer can inspect or modify the child process as desired using regular ptrace commands. - When the tracer is done, it calls ptrace() with PTRACE_CONT on the child, allowing the child to continue executing. - The tracer will receive a PTRACE_EVENT_CLONE event for each new child cloned by the traced process going forward. So in summary, PTRACE_O_TRACECLONE makes ptrace notify the tracer process whenever a new child process is cloned, allowing the tracer to introspect or control the child even before it starts running. This provides deeper process tracing capabilities. ================================================ FILE: book/12-appendix/3-git-bisect.md ================================================ ## Appendix: 使用 git bisect 定位引入bug的commit `git bisect` 是一个非常有用的工具,用于在大型代码库中快速定位引起特定错误或功能变化的具体提交。它通过二分查找算法,将可能包含问题的提交范围逐步缩小,从而帮助开发者更快地找到引入bug的那个commit。 下面是一个使用 `git bisect` 搜索引入bug的commit的示例。这个例子假设你已经知道一个特定版本没有问题(比如标签v1.0),而后续的一个版本有问题(比如最新的master分支): ### 步骤 1: 开始bisect 首先,你需要从有已知错误的状态开始bisect搜索: ```bash git bisect start ``` ### 步骤 2: 指定没有问题的提交点 指定一个你确信没有任何问题的特定版本(这可以是标签、分支或具体的commit hash): ```bash git bisect good v1.0 # 假设v1.0是一个好的状态,没有bug。 ``` ### 步骤 3: 指定有问题的提交点 接着指定一个你确信存在问题的版本(这同样可以是标签、分支或具体的commit hash): ```bash git bisect bad master # 假设master是最新的开发分支,并且包含已知bug。 ``` ### 步骤 4: 编译并测试 Git会自动切换到两个指定提交之间的某个中间点(通过二分法来选择)。你需要在这个版本上进行编译和测试,以确认当前的代码是否有问题: ```bash make # 假设你的构建命令是 make。 ./test_program # 运行自定义脚本来检查是否存在bug。 ``` ### 步骤 5: 反馈测试结果给git bisect 在执行了编译和测试之后,你必须告知 `git bisect` 当前的提交是否包含问题: - 如果当前版本没有问题,则运行: ```bash git bisect good ``` - 如果当前版本有问题,则运行: ```bash git bisect bad ``` ### 步骤 6: 反复执行直到找到引入bug的commit 根据上述步骤反复进行,直到 `git` 找到引入bug的那个具体提交为止。当bisect结束时,它会打印出“首先被标记为错误”的提交信息。 ```bash # 最终会显示类似这样的内容: ``` bisect run failed: c94218e7b5d390a6c6eb7f3f7aaf5aa92e0bddd2 is the first bad commit commit c94218e7b5d390a6c6eb7f3f7aaf5aa92e0bddd2 Author: Your Name Date: Date of commit Commit message goes here :100644 100644 8d9bdc2... a91d6ae... M filename ``` 在这个示例中,`c94218e7b5d390a6c6eb7f3f7aaf5aa92e0bddd2` 就是引入bug的commit。 ### 步骤 7: 完成bisect 当你找到了引发问题的那个提交后,可以通过以下命令结束 bisect: ```bash git bisect reset ``` 这会将你的工作区恢复到开始 `git bisect` 之前的最后一个分支或标签状态。至此,你就完成了使用 `git bisect` 来定位引入bug的commit的过程。 希望这个示例对你有所帮助!如果你有更多的问题或者需要进一步的帮助,请随时提问。 ================================================ FILE: book/12-appendix/4-vscode-extension-bisect.md ================================================ ## Appendix: 使用二分查找解决扩展问题 [来源](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect "Permalink to Resolving extension issues with bisect") 2021年2月16日,作者:Johannes Rieken,[@johannesrieken](https://twitter.com/johannesrieken) > "就像 git-bisect 一样,但适用于 VS Code 扩展。" Visual Studio Code 的真正威力在于其扩展:主题扩展添加颜色和图标,语言扩展启用智能代码补全(IntelliSense)和导航,调试器扩展让你能够运行代码并轻松找到错误。有些扩展播放音乐,有些显示股票行情,还有一些扩展支持跨地点和时区的协作工作。VS Code [市场](https://marketplace.visualstudio.com/vscode)托管着超过 28,000 个扩展,用户安装 50 个或更多扩展并不罕见。有如此多的扩展,出现错误是不可避免的。与其否认这一点,我们更希望让故障排除变得简单。 ### ["问题"扩展](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect#_bad-extensions) 我们热爱扩展,并不认为真的存在"问题"扩展。然而,就像所有软件一样,扩展也有错误和功能缺陷。因此,为了阅读便利和增加戏剧效果,让我们使用"问题扩展"这个术语,指的是可能会崩溃或显示不期望行为的扩展。幸运的是,我们在设计 VS Code 时考虑了"问题"扩展的存在,因此将它们运行在单独的[进程](https://code.visualstudio.com/api/advanced-topics/extension-host)中。这种隔离保证了 VS Code 继续运行,光标始终闪烁,你总是能够保存你的工作。 为了好玩,也为了让扩展二分查找的演示更容易,我们创建并发布了 [Extension Bisect Demo](https://marketplace.visualstudio.com/items?itemName=jrieken.bisectdemo) 扩展。安装后,每当你输入"bisect"这个词时,它会烦人地重置你的光标。你可以使用这个扩展来跟随这篇博客文章。 ### [用困难的方式找到"问题"扩展](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect#_finding-a-bad-extension-the-hard-way) 如今,找到"问题"扩展可能很容易,也可能很困难。打开扩展视图(Ctrl+Shift+X),[禁用扩展](https://code.visualstudio.com/docs/configure/extensions/extension-marketplace#_disable-an-extension),重新加载窗口(**开发者:重新加载窗口**),然后检查问题是否仍然存在。如果问题消失了,那个扩展就是"问题"扩展,你就完成了。否则,重新启用该扩展并对下一个扩展重复此过程。 ![逐步禁用扩展](https://code.visualstudio.com/assets/blogs/2021/02/16/disable_manually.png) 如果你幸运的话,第一个扩展就是"问题"扩展;如果你不幸的话,它就是最后一个扩展。用计算机科学的语言来说,这意味着对于 `N` 个扩展,你在最坏情况下需要重复这个过程 `O(N)` 次(N 阶),平均情况下是 `O(N/2)` 次。因为这个算法是由人类(你)操作的,即使 `N` 值很小也很费力。这就是 **扩展二分查找** 工具派上用场的地方。它在最坏情况和平均情况下都要好得多,因为它按一半一半地禁用扩展。 ### [欢迎扩展二分查找](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect#_welcome-extension-bisect) VS Code 中的扩展二分查找工具受到 [git bisect](https://git-scm.com/docs/git-bisect) 命令的启发。对于熟悉 Git 的人来说,这个命令有助于找出仓库中哪个提交引入了问题。 让我们用一个例子:我安装了 24 个扩展,第 8 个扩展是"问题"扩展。我们知道迭代方法需要 8 步。二分查找怎么样? 下面的视频显示了通过 **帮助:开始扩展二分查找** 命令启动扩展二分查找,然后选择 **现在正常** 或 **这是问题** 直到识别出"问题"扩展。一旦识别出来,你可以选择为该扩展报告问题。 ![扩展二分查找过程](https://code.visualstudio.com/assets/blogs/2021/02/16/bisect.gif) 以下是逐步找到"问题"扩展的过程: 1. 二分查找将 24 个扩展分成两半,每半 12 个扩展,并禁用后半部分的所有 12 个扩展。 2. 在这个例子中,第 8 个扩展是"问题"扩展,所以它在前半部分,没有被禁用。事情仍然不像我们期望的那样工作。因为仍然有问题,扩展二分查找重复这个过程,将前 12 个扩展分成两部分:6 个启用,6 个禁用。所有其他扩展也重新启用。 3. 第 8 个扩展现在被禁用了。现在一切正常。这意味着二分查找可以继续处理后半部分(扩展 6-11),并将它们分成 3 个启用和 3 个禁用的扩展。 4. 现在,第 8 个扩展重新启用,问题重新出现。这意味着二分查找继续处理前半部分。它将它们分成 1 个启用和 2 个禁用的扩展。 5. 第 8 个扩展现在被禁用,一切又正常了,二分查找继续处理后半部分,将其分成 1 个启用和 1 个禁用的扩展。 6. 第 8 个扩展是唯一被禁用的扩展,问题消失了。这意味着我们已经找到了"问题"扩展,我们完成了。 ### [更快地故障排除](https://code.visualstudio.com/blogs/2021/02/16/extension-bisect#_troubleshoot-faster) 我们看到,在每一步中,二分查找都将搜索空间减半。现在步骤以对数时间运行,导致平均和最坏情况性能为 `O(log N)`。这很好,因为它扩展性很好。对于 24 个扩展,你需要 4 到 5 步来找到"问题"扩展,对于 38 个扩展,只需要多 1 步。然而,最好情况更糟,因为使用迭代方法,你可能很幸运地在第一轮就找到"问题"扩展。 请记住,扩展二分查找依赖于你给出正确的反馈。你可以很容易地欺骗它,也欺骗自己,总是回答 **现在正常**(责怪最后一个扩展)或 **这是问题**(不会找到扩展)。 另一个有用的见解是,扩展二分查找从考虑所有启用的扩展列表开始。这意味着你可以通过在开始前禁用已知的"正常"扩展,然后在之后重新启用它来将其从二分查找中排除。但是,只有当你确定该扩展不是"问题"扩展时才这样做。 最后,你可能会注意到二分查找需要额外的一步(`log2(N) + 1`)。这是因为它通过禁用所有扩展来开始第一轮。这第一步是因为你可能看到的是由 VS Code 本身引起的问题,而不是由扩展引起的,我们不想不必要地让你陷入兔子洞。 就是这样。我们希望您永远不需要使用扩展二分查找。但是,如果您确实遇到可能与扩展相关的问题,那么我们希望能够让故障排除变得更容易、更快、更愉快。 编码愉快, Johannes Rieken,VS Code 首席软件工程师 [@johannesrieken](https://twitter.com/johannesrieken) ### 参考内容 1. vscode extension bisect, https://code.visualstudio.com/blogs/2021/02/16/extension-bisect ================================================ FILE: book/12-appendix/80-go-tool-compile.md ================================================ ## 扩展阅读:Go编译器简介 `cmd/compile` 包含构成Go编译器的主要包。编译器逻辑上可分为四个阶段,我们将简要描述每个阶段,并列出包含其代码的包列表。 您可能听到过"前端(front-end)"和"后端(back-end)"这两个术语。粗略地说,它们对应我们列出的前两个阶段和后两个阶段。第三个术语"中端(middle-end)"通常指第二阶段中进行的大部分工作。 请注意,`go/*` 系列包(如 `go/parser` 和 `go/types`)主要由编译器内部API使用。由于编译器最初是用C语言编写的,`go/*` 包被开发用于编写处理Go代码的工具(如 `gofmt` 和 `vet`)。然而,随着时间推移,编译器的内部API已逐渐演变为更符合 `go/*` 包用户的习惯。 需要澄清的是,"gc"代表"Go编译器",与表示垃圾回收的大写"GC"无关。 ### 1. 解析(Parsing) * `cmd/compile/internal/syntax`(词法分析器、语法分析器、语法树) 编译的第一阶段将源代码进行分词(词法分析)、解析(语法分析),并为每个源文件构建语法树。 每个语法树都是相应源文件的精确表示,节点对应源代码的各种元素(如表达式、声明和语句)。语法树还包含位置信息,用于错误报告和调试信息生成。 ### 2. 类型检查(Type checking) * `cmd/compile/internal/types2`(类型检查) `types2` 包是将 `go/types` 移植到使用 `syntax` 包的AST而非 `go/ast` 的版本。 ### 3. 中间表示构建(IR construction, "noding") * `cmd/compile/internal/types`(编译器类型) * `cmd/compile/internal/ir`(编译器AST) * `cmd/compile/internal/noder`(创建编译器AST) 编译器中端使用自己的AST定义和Go类型表示(源自C语言版本)。类型检查后的下一步是将 `syntax` 和 `types2` 表示转换为 `ir` 和 `types`。此过程称为"noding"。 使用称为统一IR(Unified IR)的技术构建节点表示,该技术基于第2阶段类型检查代码的序列化版本。统一IR还参与包的导入导出和内联优化。 ### 4. 中端优化(Middle end) * `cmd/compile/internal/inline`(函数调用内联) * `cmd/compile/internal/devirtualize`(已知接口方法调用的虚函数消除) * `cmd/compile/internal/escape`(逃逸分析) 对IR表示执行多个优化过程: - 死代码消除 - (早期)虚函数消除 - 函数调用内联 - 逃逸分析 早期死代码消除集成在统一IR写入阶段。 ### 5. 遍历(Walk) * `cmd/compile/internal/walk`(求值顺序、解糖) 对IR表示的最后一步处理是"walk",其作用包括: 1. 将复杂语句分解为简单语句,引入临时变量并保持求值顺序(也称为"order"阶段) 2. 将高级Go构造解糖为原始形式。例如: - `switch` 语句转换为二分查找或跳转表 - map和channel操作替换为运行时调用 ### 6. 通用SSA(Generic SSA) * `cmd/compile/internal/ssa`(SSA传递和规则) * `cmd/compile/internal/ssagen`(将IR转换为SSA) 在此阶段,IR被转换为静态单赋值(SSA)形式,这是一种具有特定属性的低级中间表示,便于实现优化和最终生成机器码。 转换过程中应用函数内联(intrinsics)——编译器针对特定情况用高度优化的代码替换的特殊函数。某些节点也会降级为更简单的组件(例如 `copy` 内置函数替换为内存移动,`range` 循环重写为 `for` 循环)。出于历史原因,部分转换目前发生在SSA转换之前,但长期计划是将所有转换集中于此阶段。 随后执行一系列与机器无关的传递和规则,包括: - 死代码消除 - 删除冗余的空指针检查 - 删除未使用的分支 通用重写规则主要涉及表达式优化,例如用常量替换某些表达式,优化乘法和浮点运算。 ### 7. 生成机器码(Generating machine code) * `cmd/compile/internal/ssa`(SSA降级和架构相关传递) * `cmd/internal/obj`(机器码生成) 编译器的机器相关阶段从"降级(lower)"传递开始,将通用值重写为其机器特定变体。例如,在amd64架构上允许内存操作数,因此许多加载-存储操作可以合并。 请注意,降级传递运行所有机器特定重写规则,因此当前也执行大量优化。 一旦SSA被"降级"并针对目标架构具体化,将运行最终代码优化传递,包括: - 另一次死代码消除 - 将值移近使用位置 - 删除从未读取的局部变量 - 寄存器分配 此步骤的其他重要工作包括: - 栈帧布局(为局部变量分配栈偏移) - 指针存活分析(计算每个GC安全点上栈上指针的存活状态) SSA生成阶段结束时,Go函数已转换为一系列 `obj.Prog` 指令。这些指令传递给汇编器(`cmd/internal/obj`),后者将其转换为机器码并输出最终目标文件。目标文件还将包含反射数据、导出数据和调试信息。 ### 7a. 导出(Export) 除了为链接器编写目标文件外,编译器还为下游编译单元编写"导出数据"文件。导出数据包含编译包P时计算的以下信息: - 所有导出声明的类型信息 - 可内联函数的IR - 可能在其他包实例化的泛型函数的IR - 函数参数逃逸分析结果的摘要 导出数据格式经历多次迭代,当前版本称为"统一格式"(unified),它是对象图的序列化表示,带有允许延迟解码部分内容的索引(因为大多数导入仅用于提供少数符号)。 GOROOT仓库包含统一格式的读取器和写入器;它从/向编译器的IR进行编码和解码。`golang.org/x/tools` 仓库也为导出数据读者提供公共API(使用 `go/types` 表示),始终支持编译器的当前文件格式和少量历史版本。(`x/tools/go/packages` 在需要类型信息但不需要带类型注释的语法模式中使用它。) `x/tools` 仓库还为使用旧版"索引格式"的导出类型信息(仅限类型信息)提供公共API。(例如,`gopls` 使用此版本存储工作区信息数据库,其中包括类型信息。) 导出数据通常提供"深度"摘要,因此编译包Q只需读取每个直接导入的导出数据文件,即可确保这些文件提供间接导入(如P的公共API中引用的类型的方法和结构字段)的所有必要信息。深度导出数据简化了构建系统,因为每个直接依赖只需要一个文件。然而,当处于大型仓库的导入图较高层时,这会导致导出数据膨胀:如果有常用类型具有大型API,几乎每个包的导出数据都会包含副本。这一问题推动了"索引"设计的发展,该设计允许按需部分加载。 ### 8. 实用技巧 #### 入门指南 * 如果您从未贡献过编译器,简单的方法是在感兴趣的位置添加日志语句或 `panic("here")` 以初步了解问题。 * 编译器本身提供日志、调试和可视化功能: ```bash $ go build -gcflags=-m=2 # 打印优化信息(包括内联、逃逸分析) $ go build -gcflags=-d=ssa/check_bce/debug # 打印边界检查信息 $ go build -gcflags=-W # 打印类型检查后的内部解析树 $ GOSSAFUNC=Foo go build # 为函数Foo生成ssa.html文件 $ go build -gcflags=-S # 打印汇编代码 $ go tool compile -bench=out.txt x.go # 打印编译器阶段的计时信息 ``` * 部分标志会改变编译器行为,例如: ```bash $ go tool compile -h file.go # 遇到第一个编译错误时恐慌 $ go build -gcflags=-d=checkptr=2 # 启用额外的unsafe指针检查 ``` * 更多标志详情可通过以下方式获取: ```bash $ go tool compile -h # 查看编译器标志(如 -m=1 -l) $ go tool compile -d help # 查看调试标志(如 -d=checkptr=2) $ go tool compile -d ssa/help # 查看SSA标志(如 -d=ssa/prove/debug=2) ``` #### 测试修改 * 请务必阅读 [快速测试修改](https://go.dev/doc/contribute#quick_test) 部分。 * 部分测试位于 `cmd/compile` 包内,可通过 `go test ./...` 运行,但许多测试位于顶级 [test](https://github.com/golang/go/tree/master/test) 目录: ```bash $ go test cmd/internal/testdir # 运行'test'目录所有测试 $ go test cmd/internal/testdir -run='Test/escape.*.go' # 运行特定模式的测试 ``` 详情参见 [testdir README](https://github.com/golang/go/tree/master/test#readme)。 `testdir_test.go` 中的 `errorCheck` 方法有助于解析测试中使用的 `ERROR` 注释。 * 新的 [基于应用的覆盖率分析](https://go.dev/testing/coverage/) 可用于编译器: ```bash $ go install -cover -coverpkg=cmd/compile/... cmd/compile # 构建带覆盖率检测的编译器 $ mkdir /tmp/coverdir # 选择覆盖率数据存放位置 $ GOCOVERDIR=/tmp/coverdir go test [...] # 使用编译器并保存覆盖率数据 $ go tool covdata textfmt -i=/tmp/coverdir -o coverage.out # 转换为传统覆盖率格式 $ go tool cover -html coverage.out # 通过传统工具查看覆盖率 ``` #### 处理编译器版本 * 许多编译器测试使用 `$PATH` 中的 `go` 命令及其对应的 `compile` 二进制文件。 * 如果您在分支中且 `$PATH` 包含 `/bin`,执行 `go install cmd/compile` 将使用分支代码构建编译器,并安装到正确位置,以便后续 `go` 命令使用新编译器。 * [toolstash](https://pkg.go.dev/golang.org/x/tools/cmd/toolstash) 提供保存、运行和恢复Go工具链已知良好版本的功能。例如: ```bash $ go install golang.org/x/tools/cmd/toolstash@latest $ git clone https://go.googlesource.com/go $ cd go $ git checkout -b mybranch $ ./src/all.bash # 构建并确认良好起点 $ export PATH=$PWD/bin:$PATH $ toolstash save # 保存当前工具链 ``` 之后编辑/编译/测试循环类似: ```bash <... 修改cmd/compile源码 ...> $ toolstash restore && go install cmd/compile # 恢复已知良好工具链构建编译器 <... 'go build', 'go test', etc. ...> # 使用新编译器进行测试 ``` * `toolstash` 还允许比较已安装与存储版本的编译器,例如验证重构后行为一致性: ```bash $ toolstash restore && go install cmd/compile # 构建最新编译器 $ go build -toolexec "toolstash -cmp" -a -v std # 比较新旧编译器生成的std库 ``` * 如果版本不同步(例如出现 `linked object header mismatch` 错误),可执行: ```bash $ toolstash restore && go install cmd/... ``` #### 其他有用的工具 * [compilebench](https://pkg.go.dev/golang.org/x/tools/cmd/compilebench) 用于基准测试编译器速度。 * [benchstat](https://pkg.go.dev/golang.org/x/perf/cmd/benchstat) 是报告编译器修改性能变化的标准工具: ```bash $ go test -bench=SomeBenchmarks -count=20 > new.txt # 使用新编译器测试 $ toolstash restore # 恢复旧编译器 $ go test -bench=SomeBenchmarks -count=20 > old.txt # 使用旧编译器测试 $ benchstat old.txt new.txt # 对比结果 ``` * [bent](https://pkg.go.dev/golang.org/x/benchmarks/cmd/bent) 可方便地在Docker容器中运行社区Go项目的基准测试集。 * [perflock](https://github.com/aclements/perflock) 通过控制CPU频率等手段提高基准测试一致性。 * [view-annotated-file](https://github.com/loov/view-annotated-file) 可将内联、边界检查和逃逸信息叠加显示在源代码上。 * [godbolt.org](https://go.godbolt.org) 广泛用于查看和分享汇编输出,支持比较不同Go编译器版本的汇编代码。 --- ### 进阶阅读 如需深入了解SSA包的工作原理(包括其传递和规则),请参阅 [cmd/compile/internal/ssa/README.md](internal/ssa/README.md)。 如果本文档介绍或SSA README有任何不清楚之处,或您有改进建议,请在 [issue 30074](https://go.dev/issue/30074) 中留言。 ================================================ FILE: book/12-appendix/81-go-tool-asm.md ================================================ ## 扩展阅读:Go汇编器简介 ### 1. Plan9项目和Go语言的关系 Plan9是Bell实验室的一个研究性质的分布式操作系统,Go语言早期核心开发人员多来自这个项目组,他们将设计实现Plan9过程中的一些经验带到了Go语言中来,特别是a.out文件格式、plan9汇编、工具链这块。 see: https://man.cat-v.org/plan_9/6/a.out,比如: - 汇编器输出后的目标文件格式、运行时会依赖的symtab+pclntab; - 采用的汇编语言、伪寄存器(fp,sb,sp,pc); - `FP`: Frame pointer: arguments and locals. - `PC`: Program counter: jumps and branches. - `SB`: Static base pointer: global symbols. - `SP`: Stack pointer: the highest address within the local stack frame. in Go, all user-defined symbols are written as offsets to the pseudo-registers `FP` (arguments and locals) and `SB` (globals). ### 2. Plan9 和其它OS不一样之处 Plan9本身是一个实验操作系统,它也有些设计实现上不同寻常操作系统的地方: - 一切皆文件,包括对网络套接字甚至对远程计算机的操作,API都是通过一套编程接口,比Unix、Linux操作系统设计的还要绝; - 有点奇葩的工具链命名, - 2c,3c,4c...8c,这些都是编译器,将.c源码编译为plan9汇编文件; - 2a,3a,4a...8a,这些都是汇编器,将.s源码汇编为目标文件; ps: object file, 翻译为目标文件好,还是对象文件好,@gemini表示翻译为目标文件好,ok! 目标程序的一部分。 - 2l,3l,4l...8l,这些都是加载器,将可执行文件加载前会完成常规linker要做的符号解析、重定位的操作,然后再加载到内存中; ps:没有专门的linker,plan9的loader存在一部分常规linker的功能,well,ok! - 汇编指令是一种semi-abstract instrution set,并不严格对应特定平台上的指令操作,比如MOV操作,指令选择阶段,会选择不同平台特定的机器指令,这点plan9和go都是这样的; > **Plan9 loaders**: 关于Plan9 loader 2l,3l,..8l的疑问,为什么没有专门的linker?Plan9 loaders是不是具备常规linker的功能? > 在 Plan 9 操作系统中,“加载器”(例如 Intel 386 的 `8l`)所扮演的角色与传统意义上的“链接器”有很大程度的重叠。 关于Plan9 loaders的功能说明: * **编译器与加载器:** * Plan 9 的编译器(如 `8c`)生成目标文件。 * 然后,加载器获取这些目标文件并生成最终的可执行文件。 * **加载器的功能:** * Plan 9 的加载器不仅仅是执行典型的运行时“加载”操作。它还执行关键的链接任务,包括: * **符号解析:** 解析不同目标文件和库之间的引用。 * **机器码生成:**在plan9中,加载器才是真正生成最终机器码的程序。 * **指令选择:**选择最有效的机器指令。 * **分支折叠和指令调度:**优化可执行文件。 * **库链接:**自动链接必要的库。 * **关键区别:** * 一个显著的区别是,Plan 9 加载器处理了大部分最终机器码的生成,而在许多其他系统中,这部分工作是在编译过程的早期完成的。这意味着plan9编译器产生的是一种抽象的汇编,而加载器将其转换为最终的机器码。 * **本质上:** * Plan 9 加载器的功能不仅仅是加载,它还包含了核心的链接职责。 ### 3. Plan9和Go汇编器异同点 要充分掌握Go汇编,就得了解它的前身及它自己的演进,也就是说了解Plan9汇编器,以及Go中特殊的地方。 - [a manual for plan9 assembler, rob pike](https://doc.cat-v.org/plan_9/4th_edition/papers/asm) - [a quick guide to Go's assembler](https://go.dev/doc/asm) 后续有机会,可以在我的博客里总结下Go汇编器的使用,本电子书中我们就不过多展开了,我们这里只介绍下Go汇编器的主要工作即可,我们不会考虑调试期间对Go汇编进行特殊支持 …… 这不在我们计划中,除非我们有大把时间。 ### 参考文献 - plan9 a.out目标文件格式, https://man.cat-v.org/plan_9/6/a.out - plan9 assemblers, https://man.cat-v.org/plan_9/1/2a - plan9 compilers, https://man.cat-v.org/plan_9/1/2c - plan9 loaders, https://man.cat-v.org/plan_9/1/2l - plan9 used compilers, https://doc.cat-v.org/bell_labs/new_c_compilers/new_c_compiler.pdf > “ *This paper describes yet another series of C compilers. These compilers were developed over the last several years and are now in use on Plan 9. These compilers are experimental in nature and were developed to try out new ideas. Some of the ideas were good and some not so good.* ” > - how to use plan9 c compiler, rob pike, https://doc.cat-v.org/plan_9/4th_edition/papers/comp - a manual for plan9 assembler, rob pike, https://doc.cat-v.org/plan_9/4th_edition/papers/asm - a quick guide to Go's assembler, https://go.dev/doc/asm ================================================ FILE: book/12-appendix/82-go-tool-link.md ================================================ ## 扩展阅读:Go链接器简介 ### 1. Go语言链接器是什么? Go语言的链接器是Go工具链中的一个关键组成部分,负责将编译生成的目标文件(如.o文件)连接成最终的可执行文件、共享库或静态库。在Go生态系统中,链接器通常被称为`go tool link`,它是Go语言编译过程中的最后一步,确保所有模块和依赖项正确地组合在一起。 ### 2. Go语言链接器的工作原理 #### 基本流程 1. **输入文件处理**:链接器接收多个目标文件(`.o`或`.obj`)、静态库(如`.a`文件)以及可能的共享库。 2. **符号解析与重定位**: - 链接器扫描所有输入文件,解析其中未定义的符号。这些符号可能来自其他目标文件、库或Go语言运行时环境。 - 对于每个符号引用,链接器查找其定义位置,并记录下需要进行重定位的操作(如调整指针以正确指向函数或变量)。 3. **段和节的合并**: - 将所有输入文件中的相同类型的段(如`text`段用于代码、`data`段用于初始化数据)合并到一起。 - 处理各个段中的重定位信息,确保所有指针和偏移量正确无误。 4. **输出生成**:将处理后的段组合成最终的可执行文件或库。 #### 内部机制 - **符号表管理**:链接器维护一个全局符号表,用于跟踪已解析的符号及其地址。这包括函数、变量以及其他标识符。 - **重定位记录**:在编译阶段生成的目标文件中包含重定位信息,告诉链接器哪些位置需要调整以指向正确的符号或节的位置。 - **依赖处理**:Go语言的模块系统允许项目依赖于多个包,链接器会自动将这些外部库包含进来,确保所有必要的代码和资源都被整合到最终输出中。 ### 3. 编译器与链接器的协同工作 #### 相关Sections 在编译过程中,Go编译器生成以下几个关键段: - **`text`**:存储可执行代码。 - **`data`**:用于初始化的数据(如全局变量)。 - **`rodata`**:只读数据,通常包含常量字符串和编译时常量。 - **`bss`**:未初始化的零初始化数据段。 编译器负责将源代码转换为这些段中的内容,并在生成的目标文件中记录必要的重定位信息。链接器的任务是将所有目标文件中的相应段合并,并解决符号依赖关系,确保最终程序或库能够在运行时正确执行。 #### 协作流程 1. **编译阶段**:每个Go源文件被分割成多个段,并生成包含重定位指令的信息。 2. **链接阶段**: - 链接器读取所有目标文件和库的段信息。 - 解析未定义符号,可能需要查找标准库或其他依赖库中的实现。 - 合并各个段(如将所有`text`段合并为一个连续的代码段)。 - 应用重定位操作,调整指针地址以反映实际内存布局。 ### 4. ELF文件中的Program Header Table 在ELF(Executable and Linkable Format)文件中,`program header table`是由编译器和链接器共同作用的结果。具体来说: - **编译器**:生成初始的段信息,并创建基本的程序头表结构。 - **链接器**:调整和完善这些段的布局,更新程序头表中的偏移量、大小等信息,以确保最终文件能够被操作系统正确加载。 总结来说,虽然编译器为ELF文件奠定了基础,但链接器负责将其转化为适合执行的形式,包括调整段的位置和属性,使程序能够在目标环境中运行。 ### 5. 参考文献 - TODO [Internals of the Go Linker by Jessie Frazelle](https://www.youtube.com/watch?v=NLl5zwl9Hk8) - [Golang Internals, Part 2: Diving Into the Go Compiler](https://www.altoros.com/blog/golang-internals-part-2-diving-into-the-go-compiler/) - [Golang Internals, Part 3: The Linker, Object Files, and Relocations](https://www.altoros.com/blog/golang-internals-part-3-the-linker-object-files-and-relocations/) - [Golang Internals, Part 4: Object Files and Function Metadata](https://www.altoros.com/blog/golang-internals-part-4-object-files-and-function-metadata/) - TODO [Linkers and Loaders](https://www.amazon.com/Linkers-Kaufmann-Software-Engineering-Programming/dp/1558604960) 通过这些参考资料,可以更全面地理解Go语言链接器的工作机制及其在编译过程中的重要性。 ================================================ FILE: book/12-appendix/90-why-buildid-loaded.md ================================================ ## 问题探讨:why load buildid sections ### 先说点结论性的 涉及到build id这个概念的sections主要有两个,.note.go.buildid,以及.note.gnu.build-id,前者就是大家熟知的go tool buildid `` 显示的buildid,后者是更多的Linux生态中的工具使用的。 前面讲解ELF文件段头表的时候发现个问题,.note.go.build, .note.gnu.build-id 为何会被加载到内存中呢?有几个猜测: - 程序运行时希望不读取ELF文件直接获取这些buildid信息; - 程序生成内存转储后,希望将这些信息包含在core文件中,方便其他工具从core文件中提取buildid信息与符号 .note.gnu.build-id,这个的作用是用来跟踪构建时的代码版本、代码目录、构建环境是否一致,有些构建系统会记录这个buildid以及关联的上述信息、制品、分离的调试符号、符号表等等,在需要定位问题的时候可以按需加载这些。 .note.go.buildid,这个的作用主要是go工具链内部使用,外部工具不应该使用这个buildid。 ### 探索:pprof profile信息中希望记录下GNU build-id 在阅读了go源码以后,初步判断是pprof生成profile信息时希望能在其中记录下buildid,以方便分析时用来跟踪版本、构建环境、符号信息,这些信息可能构建系统会自己通过数据库维护起来。但是仔细查看后这里的buildid是.note.gnu.build-id中的build-id,而非go buildid,前者是一些工具通用的 从/proc/pid/maps获取对应的GNU build-id的源码,大致如下: ``` // newProfileBuilder returns a new profileBuilder. // CPU profiling data obtained from the runtime can be added // by calling b.addCPUData, and then the eventual profile // can be obtained by calling b.finish. func newProfileBuilder(w io.Writer) *profileBuilder { zw, _ := gzip.NewWriterLevel(w, gzip.BestSpeed) b := &profileBuilder{ ... } b.readMapping() return b } // readMapping reads /proc/self/maps and writes mappings to b.pb. // It saves the address ranges of the mappings in b.mem for use // when emitting locations. func (b *profileBuilder) readMapping() { data, _ := os.ReadFile("/proc/self/maps") parseProcSelfMaps(data, b.addMapping) ... } func parseProcSelfMaps(data []byte, addMapping func(lo, hi, offset uint64, file, buildID string)) { // $ cat /proc/self/maps // 00400000-0040b000 r-xp 00000000 fc:01 787766 /bin/cat // 0060a000-0060b000 r--p 0000a000 fc:01 787766 /bin/cat // 0060b000-0060c000 rw-p 0000b000 fc:01 787766 /bin/cat // 014ab000-014cc000 rw-p 00000000 00:00 0 [heap] // 7f7d76af8000-7f7d7797c000 r--p 00000000 fc:01 1318064 /usr/lib/locale/locale-archive // 7f7d7797c000-7f7d77b36000 r-xp 00000000 fc:01 1180226 /lib/x86_64-linux-gnu/libc-2.19.so // 7f7d77b36000-7f7d77d36000 ---p 001ba000 fc:01 1180226 /lib/x86_64-linux-gnu/libc-2.19.so ... // 7f7d77f65000-7f7d77f66000 rw-p 00000000 00:00 0 // 7ffc342a2000-7ffc342c3000 rw-p 00000000 00:00 0 [stack] // 7ffc34343000-7ffc34345000 r-xp 00000000 00:00 0 [vdso] // ffffffffff600000-ffffffffff601000 r-xp 00000000 00:00 0 [vsyscall] ... for len(data) > 0 { ... buildID, _ := elfBuildID(file) addMapping(lo, hi, offset, file, buildID) } } // elfBuildID returns the GNU build ID of the named ELF binary, // without introducing a dependency on debug/elf and its dependencies. func elfBuildID(file string) (string, error) { ... } ``` ### 探索:测试下pprof profile信息中包含GNU build-id 在这个基础上,我们生成个pprof profile信息,然后查看下是否有记录这个GNU build-id: ``` $ cat main.go package main import ( "log" "os" "runtime/pprof" ) func main() { f, err := os.Create("profile.pb.gz") if err != nil { log.Fatal(err) } pprof.StartCPUProfile(f) defer pprof.StopCPUProfile() var i int64 for i = 0; i < (1 << 33); i++ { } } ``` ```bash $ go build -ldflags "-B gobuildid" main.go $ file main main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, BuildID[sha1]=f4b5d514bc46fad9417898216b23910ae874a85d, with debug_info, not stripped $ readelf -n main Displaying notes found in: .note.gnu.build-id Owner Data size Description GNU 0x00000014 NT_GNU_BUILD_ID (unique build ID bitstring) Build ID: f4b5d514bc46fad9417898216b23910ae874a85d Displaying notes found in: .note.go.buildid Owner Data size Description Go 0x00000053 GO BUILDID description data: 45 72 5a 36 6f 30 30 37 79 53 35 48 4c 67 41 7a 51 66 6e 52 2f 42 5a 53 51 58 54 4b 49 35 53 61 61 4f 4d 6e 65 49 36 63 56 2f 52 37 41 42 44 38 68 6c 34 6c 6b 65 79 44 66 7a 35 35 69 4d 2f 73 58 6a 56 4b 38 6d 52 58 79 35 4d 79 41 73 46 46 52 6d 74 $ ./main $ pprof -raw profile.pb.gz | grep -A10 Mappings Mappings 1: 0x400000/0x4ac000/0x0 /tmp/main f4b5d514bc46fad9417898216b23910ae874a85d [FN] ``` 注意这里的GNU build-id不是默认生成的,需要显示传 -ldflags "-B ..." 来指定,如果不指定的话,就没有这个信息: ``` $ go build main.go $ file main main: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, Go BuildID=llrn1go725_F2vCvvETz/OITeRu6kDScHG6FVjdK8/R7ABD8hl4lkeyDfz55iM/uoTostDrfB5kdwhy6UpG, with debug_info, not stripped $ readelf -n main Displaying notes found in: .note.go.buildid Owner Data size Description Go 0x00000053 GO BUILDID description data: 6c 6c 72 6e 31 67 6f 37 32 35 5f 46 32 76 43 76 76 45 54 7a 2f 4f 49 54 65 52 75 36 6b 44 53 63 48 47 36 46 56 6a 64 4b 38 2f 52 37 41 42 44 38 68 6c 34 6c 6b 65 79 44 66 7a 35 35 69 4d 2f 75 6f 54 6f 73 74 44 72 66 42 35 6b 64 77 68 79 36 55 70 47 $ ./main $ pprof -raw profile.pb.gz | grep -A10 Mappings Mappings 1: 0x400000/0x4ac000/0x0 /tmp/main [FN] ``` ### 探索:为什么text segment要包含buildid? coredump? 但是我们想搞清楚的是,这么一个GNU build-id或者go buildid,链接器创建对应的segment的时候为什么非要将其和.text section一起定义为PT_LOAD类型,毕竟没有工具直接从二进制中去读它。实际上如果没有原始ELF信息中的sections、segments信息也不知道进程中该buildid应该位于内存地址的什么位置、占多少字节,还是没法解析。实际上现在看go官方工具链里至少也没有直接这么去读的,都最终还是读的ELF文件中的sections来拿到这个GNU build-id或者go buildid信息的。 我现在能联想到的就是,如果不是一个bug的话,那很可能是希望在生成core文件 或者 内存转储(dump memory)的时候能把这部分信息存下来,好方便确定生成core文件的builid,以和构建系统中维护的信息建立联系。下面举个这样的例子: 启动一个go程序 myapp 并生成 core 文件 1. go build -o main main.go 2. ./main 3. gcore -o main.core $(pidof main) 加载这个core文件,并读取buildid信息 1. gdb main.core main 2. gdb> maintenance info sections ```bash Exec file: `/home/zhangjie/test/main', file type elf64-x86-64. [0] 0x00401000->0x00480c75 at 0x00001000: .text ALLOC LOAD READONLY CODE HAS_CONTENTS [1] 0x00481000->0x004be35d at 0x00081000: .rodata ALLOC LOAD READONLY DATA HAS_CONTENTS [2] 0x004be360->0x004be8f0 at 0x000be360: .typelink ALLOC LOAD READONLY DATA HAS_CONTENTS [3] 0x004be900->0x004be958 at 0x000be900: .itablink ALLOC LOAD READONLY DATA HAS_CONTENTS [4] 0x004be958->0x004be958 at 0x000be958: .gosymtab ALLOC LOAD READONLY DATA HAS_CONTENTS [5] 0x004be960->0x00523070 at 0x000be960: .gopclntab ALLOC LOAD READONLY DATA HAS_CONTENTS [6] 0x00524000->0x00524150 at 0x00124000: .go.buildinfo ALLOC LOAD DATA HAS_CONTENTS [7] 0x00524160->0x00529600 at 0x00124160: .noptrdata ALLOC LOAD DATA HAS_CONTENTS [8] 0x00529600->0x0052d850 at 0x00129600: .data ALLOC LOAD DATA HAS_CONTENTS [9] 0x0052d860->0x0058d390 at 0x0012d860: .bss ALLOC [10] 0x0058d3a0->0x00590de0 at 0x0018d3a0: .noptrbss ALLOC [11] 0x00000000->0x00000214 at 0x0012e000: .debug_abbrev READONLY HAS_CONTENTS [12] 0x00000000->0x00037302 at 0x0012e135: .debug_line READONLY HAS_CONTENTS [13] 0x00000000->0x00012674 at 0x0014d803: .debug_frame READONLY HAS_CONTENTS [14] 0x00000000->0x0000002a at 0x00153a70: .debug_gdb_scripts READONLY HAS_CONTENTS [15] 0x00000000->0x000928ac at 0x00153a9a: .debug_info READONLY HAS_CONTENTS [16] 0x00000000->0x000a772c at 0x00191567: .debug_loc READONLY HAS_CONTENTS [17] 0x00000000->0x0003e3a0 at 0x001adaae: .debug_ranges READONLY HAS_CONTENTS [18] 0x00400fdc->0x00401000 at 0x00000fdc: .note.gnu.build-id ALLOC LOAD READONLY DATA HAS_CONTENTS [19] 0x00400f78->0x00400fdc at 0x00000f78: .note.go.buildid ALLOC LOAD READONLY DATA HAS_CONTENTS Core file: `/home/zhangjie/test/mycore.444388', file type elf64-x86-64. [0] 0x00000000->0x00002798 at 0x00000548: note0 READONLY HAS_CONTENTS [1] 0x00000000->0x000000d8 at 0x00000668: .reg/444388 HAS_CONTENTS [2] 0x00000000->0x000000d8 at 0x00000668: .reg HAS_CONTENTS ... ``` 3. 生成转储,包含了go buildid ```bash gdb$ dump memory dump.go.buildid 0x00400f78 0x00400fdc ``` 4. 生成转储,包含了GNU build-id ```bash gdb$ dump memory dump.gnu.buildid 0x00400fdc 0x00401000 ``` 对比分析上述内存中转出来的buildid信息与ELF文件中数据是否一致: - 查看上述内存转储数据可以使用 `strings`、`hexdump`; - 查看ELF文件中数据 `file`、`readelf -S
--string-dump=|--hex-dump=`; - 对比发现是一致的。 ### 探索:似乎除了core没有其他理由要load上述sections? 但是呢?还是那句话,如果我们拿不到原始的executable文件,拿不到对应的ELF sections、segments信息,上面调试器也输出不了各个sections在内存中的地址,我们也不方便内存转储后分析。 思来想去,将这个.note.gnu.build-id和.note.go.builid加载到内存,唯一可能的原因就是为了生成core文件的时候能够包含这个信息了。 > ps: 得有工具帮助跟踪这个core文件的pid对应的二进制文件的映射关系。 Read More: - [what does go build -ldflags "-B [0x999|gobuildid]" do](https://go-review.googlesource.com/c/go/+/511475#related-content) , 这个其实就是想在ELF里记录一个GNU buildid,但是能从go buildid派生出来,不用外部系统重复做这个计算工作。这个buildid可以用来用来追踪构建有没有发生改变,有些外部系统会维护一个数据库记录构建时代码版本、符号信息以及与buildid的映射关系,方便进行问题定位、制品跟踪等。 - .note.gnu.build-id 可以由外部系统构建好在编译的时候传入(go build -ldflags "-B ``"),也可以通过.note.go.buildid生成规则来派生一个出来(go build -ldflags "-B gobuildid")。 - .note.gnu.build-id 是很多通用工具会去读取的,而.note.go.buildid定位上是只给go官方工具链中的内部工具使用。 - 不管怎么样吧,现在pprof profile信息里记录这个GNU build-id的时候也是通过先读取 /proc/`/maps然后找到可执行权限的mmaped的文件,然后再去读取这个文件找到对应的section .note.gnu.build-id来读取的。这部分代码写的很重复,实际上只是为了避免引入标准库中的东西,不想导入那么多依赖,所以是自己读取后来解析的。` Well, I'm still a little confused: - 通过这个来跟踪下讨论进展:https://groups.google.com/g/golang-nuts/c/Pv5gPIUTVyY ================================================ FILE: book/12-appendix/91-syntax-and-semantic-analysis.md ================================================ ## 扩展阅读:解释下语法分析和语义分析的区别 ### 回顾编译过程 学习过编译原理的话,编译过程包含的主要步骤,我们应该都有这方面认识。Go编译期编译过程主要包含这么几个步骤: - 词法分析(Lexical Analysis):将源代码转换为单词流(tokens),识别关键字、标识符、运算符等; - 语法分析(Syntax Analysis):将 token 流解析为抽象语法树(AST); - 语义分析(Semantic Analysis):检查 AST 的语义,处理变量声明、类型检查、作用域等; - 中间代码生成(Intermediate Code Generation):将 AST 转换为静态单赋值(SSA)形式,便于优化; - 目标代码生成(Target Code Generation):将 SSA 中间表示转换为平台特定的汇编代码; > ps: ELF 符号表和调试信息(DWARF),编译器在处理中间代码时,会收集符号信息并生成 DWARF 调试数据。 ### 语法 vs 语义 其中语法分析、语义分析,没有其他几个步骤字面上区分度那么高,如果没有亲自尝试写编译器,只是看这个术语本身的话,很容易混淆它们的不同。其实,**语法分析和语义分析,是有明显不同之处的:** **1. 目标不同** - **语法分析**的主要目的是验证源代码是否符合语言的语法规则,并将其转换为抽象语法树(AST)。这一步骤关注的是代码的结构是否正确,确保没有语法错误。 - **语义分析**则侧重于理解代码的意义和逻辑。它在生成的AST基础上进行进一步的处理,如类型检查、作用域分析等,以确保代码在语义上是正确的。 **2. 输入与输出不同** - **语法分析**的输入是源代码文本,其输出是一个抽象语法树(AST),表示代码的结构。 - **语义分析**的输入是抽象语法树(AST),其输出是对程序进行了一系列语义检查和处理后的中间表示,确保每个元素在类型和逻辑上都是合理的。 **3. 关注点不同** - 在**语法分析**中,编译器关注的是代码的形式结构,比如词法单元是否正确组合成有效的语句和表达式。 - 而在**语义分析**中,编译器不仅检查结构的正确性,还要确保变量的使用符合其声明类型,函数调用参数与定义一致等。 **4. 错误类型** - **语法分析**过程中发现的错误通常是词法或语法错误,如拼写错误、括号不匹配等。 - **语义分析**过程中发现的错误是语义错误,如类型不兼容、变量未声明等。 **5. 实现步骤** - **语法分析**一般包括: - 扫描源代码生成token流。 - 解析token流生成AST。 - **语义分析**一般包括: - 建立符号表,管理变量的作用域。 - 进行类型检查,确保操作的合法性。 - 处理表达式和语句的语义信息。 **6. 在Go编译器中的实现** - **语法分析**主要由 `parser.go`文件中的 `ParseFile()`函数实现,生成AST。 - **语义分析**主要由 `noder.go`文件中的 `NewNoder()`和 `Noder.Emit()`函数实现,处理中间表示并进行类型检查。 **7. 示例** 举个简单的例子: ```go func main() { var a int a = 5 } ``` - **语法分析**会将这段代码转换为一个AST,包含函数定义、变量声明和赋值操作。 - **语义分析**则会在生成的AST基础上检查: - `a`是否在作用域内被正确声明。 - 赋值操作中5是否与int类型兼容。 - 函数调用等其他可能的操作是否符合语义规则。 通过以上步骤和示例,可以看出语法分析和语义分析虽然都是编译过程中的关键阶段,但它们关注的焦点和处理的内容是不同的。语法分析确保代码的结构正确,而语义分析则确保代码在逻辑上合理且无误。 ================================================ FILE: book/12-appendix/92-why-gdb-uses-symtab.md ================================================ ## 扩展阅读:GDB为什么同时使用.symtab和DWARF ### **GDB 使用 `.symtab` 吗?** 是的,它使用,而且是作为非常基础的依赖数据在使用。 ### 为什么使用 `.symtab`? `.symtab` (符号表) 是 ELF (可执行和可链接格式) 文件的一个核心组成部分。它包含以下信息: * **函数名:** 程序中函数的名称。这对于单步调试、设置断点和理解程序执行流程至关重要。 * **变量名:** 全局和静态变量的名称。虽然 GDB *可以* 访问局部变量的信息(稍后会讲到),但 `.symtab` 提供了全局可访问变量的名称。 * **符号地址:** 函数和变量在内存中的地址。这对于 GDB 在调试时定位它们是必需的。 * **节信息:** 链接到包含代码和数据的 ELF 文件节的链接。 从历史上看,`.symtab` 是主要的调试信息来源。早期的调试器,包括最初的 GDB,都是围绕它构建的。它是一个相对简单且紧凑的数据结构。如果没有它,GDB 会受到严重限制——它无法有意义地表示程序的结构。 ### 为什么不只用 DWARF 呢? 这是关键问题,答案是:**GDB *确实* 使用 DWARF 信息,但它并没有 *取代* `.symtab`。它们扮演着不同的、互补的角色。** 让我们了解一下 DWARF: * **什么是 DWARF?** DWARF (Debugging With Attributed Record Format) 是一种标准化的调试信息格式。它比 `.symtab` 更加全面。它包含: * **局部变量信息:** 这是相对于 `.symtab` 的一个 *主要* 优势。`.symtab` 通常不存储函数内部的局部变量信息。 * **类型信息:** 关于变量和函数参数的数据类型细节。 * **行号信息:** 机器指令和源代码行之间的映射关系。这使得 GDB 能够显示你单步执行时对应的源代码行。 * **参数信息:** 函数参数的信息。 * **内联函数信息:** 关于内联函数的细节。 * **为什么不能 *仅仅* 用 DWARF?** * **大小和性能:** DWARF 信息会显著增加可执行文件或共享库的大小。这会影响磁盘空间、内存使用以及潜在的加载时间。虽然存在压缩技术,但仍然是一个需要考虑的因素。 * **兼容性:** 虽然 DWARF 是标准化的,但存在不同的版本和扩展。较旧的 GDB 版本可能无法完全支持所有 DWARF 功能。`.symtab` 是一个更通用的基础。 * **符号名称:** 虽然 DWARF *可以* 包含符号名称,但它通常不是存储它们的唯一地方。`.symtab` 仍然是可靠的函数和全局变量名称来源。有时,DWARF 可能会包含混杂或不易读懂的名称。 * **历史原因和兼容性:** GDB 的核心架构是围绕 `.symtab` 构建的。虽然它已经发展到高度依赖 DWARF,但完全放弃 `.symtab` 将是一个巨大的工程,并且会破坏与旧二进制文件的兼容性。 **### GDB如何同时使用二者** **这里解释下GDB 如何同时使用两者:** 1. **初始加载:** GDB 首先使用 `.symtab` 获取基本的符号信息(函数名、地址)。 2. **使用 DWARF 补充:** 然后,它使用 DWARF 获取更详细的调试信息(局部变量、类型、行号)。 3. **结合使用:** 例如,当你单步执行时,GDB 使用 `.symtab` 找到函数地址,然后使用 DWARF 显示对应的源代码行。 **现代 GDB 和 DWARF:** 现代版本的 GDB(尤其是使用最新编译器编译的版本)高度依赖 DWARF。它提供的更丰富的调试信息显著改善了用户体验。但是,`.symtab` 仍然是一个关键的后备方案和基础元素。 很难想象 GDB 会在不久的将来完全放弃它。 **总结:** | 特性 | `.symtab` | DWARF | | ------------------ | ---------------------------- | -------------------------------------- | | **主要用途** | 基本符号信息(函数名、地址) | 详细的调试信息(局部变量、类型、行号) | | **大小** | 较小 | 较大 | | **兼容性** | 非常高 | 取决于版本 | | **局部变量** | 无 | 有 | | **类型信息** | 有限 | 丰富 | 希望这个详细的解释能让读者更清楚地了解 GDB、`.symtab` 和 DWARF 之间的关系。 ================================================ FILE: book/12-appendix/README.md ================================================ ================================================ FILE: book/2-preface/README.md ================================================ # Preface 2018年开始接触golang,在学习阶段通过调试器gdb、delve进行了大量调试分析,这些实践让我在理解go类型系统、标准库、运行时相关设计实现方面事半功倍。 我本人好奇心比较强,喜欢刨根问底,有一天突然产生了疑问:**调试器是如何工作的呢**? - 如果只是想了解调试器的大致工作原理,阅读几篇高质量博客或许就足够了。 - 但如果想要胜任调试器的开发工作,尤其是从零开始构建一个完整的调试器,那就完全是另外一回事了。 调试器的设计、实现以及调试信息标准构成了一个极其庞杂而精深的工程体系。我渴望深入探索其中的奥秘,但在我的刨根问底之下,发现市面上缺乏系统全面且高质量的技术总结。正是这种技术空白促使我开始了这本书的编写之旅。 在编写过程中,我不仅大大加深了对软件开发工具链的认识,更逐渐意识到开发一款功能全面的现代调试器所具有的深远意义:它不仅具有极高的技术含量和实践价值,更能帮助开发者从多维度巩固和完善软件开发知识体系。因此,我决心在工作之余完成这个工程,与大家分享这些宝贵的技术洞察。 由于作者水平有限,书中难免存在错误或疏漏之处,真诚希望读者能够指出问题,共同完善这份调试器设计开发的技术文档。 ================================================ FILE: book/3-terms/README.md ================================================ # Terms 在本书中,我们将介绍编译器、链接器、操作系统、调试器和调试信息标准,以及软件开发等方面的知识,使用到的术语会非常多。在此处列出常见重要术语,以便读者方便地查找。 | **Term** | **Description** | |:---------------------------|:------------------------| | Source | 源代码,如go语言编写的源代码 | | Compiler | 编译器,编译源代码为目标文件 | | Linker | 链接器,将目标文件、共享库、系统启动代码链接到一起构建可执行程序 | | Debugger | 调试器,跟踪正在运行的进程或者装载一个core文件,加载程序或core文件调试符号信息,探查、修改、控制进程运行时状态,如暂停执行并查看内存、寄存器 | | Debugger Frontend | 调试器前端,主要负责与用户进行交互,接收用户输入的调试命令,并将结果以友好的方式展示给用户。前端可以是命令行界面(CLI)、图形界面(GUI)、集成开发环境(IDE)插件等。前端本身不直接与被调试进程交互,而是通过后端完成具体的调试操作 | | Debugger Backend | 调试器后端,负责与被调试进程(tracee)进行实际的交互,包括进程控制、断点设置、内存和寄存器读写、符号信息加载等。后端通常以服务的形式运行,接收前端发来的调试命令请求,并将执行结果返回给前端。前后端之间可以通过本地调用或远程协议(如RPC)通信,实现分布式或跨平台调试 | | DWARF | DWARF,是一种调试信息标准,指导编译器将调试信息生成到目标文件中,指导链接器合并存储在多个目标文件中的调试信息,调试器将加载此调试信息。简言之,DWARF用来协调编译器、链接器和调试器之间的工作 | | Debugger types | 通常,调试器可以分为两种类型:指令级调试器和符号级调试器 | | Instruction level debugger | 指令级调试器主要面向指令级别的执行控制,如设置指令地址断点、执行到断点或单步操作等。在查看进程内存和寄存器数据时,它提供相对低级的操作,如查看内存区域数据时需要指定以二进制或十进制格式显示。指令级调试器不依赖调试符号信息,因此不支持以源代码符号进行程序控制和数据操作。 指令级调试器对于逆向工程等安全分析领域也是很常见的 | | Symbol level debugger | 符号级调试器通常具备指令级调试器的所有功能,同时依靠可执行程序中的调试符号信息,建立内存地址、指令地址与源代码之间的映射关系。它支持在源代码位置设置断点、使用源代码符号查看修改变量、通过源代码表达式设置断点条件,还可以查看当前调用堆栈。符号级调试器具备许多指令级调试器所不具备的高级功能,使得使用高级语言开发时的软件调试变得便利 | | Tracee | 泛指被调试的进程,准确地讲,tracee指的是被调试器跟踪的线程。一个被调试器调试的进程也可能是多线程程序,因此如果需要对其中的多个线程进行调试跟踪,那么就存在多个tracee | | Tracer | 泛指调试器对应的进程,严格来讲也是线程,以Linux为例tracer指的是通过ptrace_attach系统调用与tracee建立跟踪、被跟踪关系的调试器中的线程。Linux内核要求ptrace attach之后的后续ptrace请求必须来自发起ptrace attach请求的线程,因此Linux下的调试器实现往往会有一个专门线程作为tracer,负责与被调试进程中的多个tracee进行交互 | ================================================ FILE: book/4-basics/1-purposes.md ================================================ ## 4.1 调试目的 尽管开发者花费了大量精力来避免自己的代码中引入bug,但是写出bug仍然是一个很平常的事情。开发人员定位代码中的问题时,通常会借助 “**走读代码**” + “**打印日志**” 的方式来理解程序执行路径、关键变量值变化,进而推断程序执行结果是否符合预期。在某些更复杂的场景下,这种方式可能难以胜任,调试器会更好地协助我们定位问题。 调试器可以帮助我们控制tracee(被调试进程、线程)的执行,也可以观察tracee的运行时内存、寄存器状态,借此我们可以实现代码的逐语句执行、控制代码执行流程、检查变量值是否符合预期,等等。 我认为调试器对于刚入门的开发者而言,是一个不可缺少的工具,它还能加深对编程语言、内存模型、操作系统的认识。即便是对于一些从业多年的开发者而言,调试器也会是一个有用的帮手。 本书将指导我们如何开发一个面向go语言的调试器,如果读者之前有使用符号级调试器(如gdb、delve等)的经验,那对于理解本书内容将会非常有帮助。 调试器要支持的重要操作,通常包括: - 设置断点,在指定内存地址、函数、语句、文件行号处设置断点; - 单步执行,单步执行一条指令,单步执行一条语句,或运行到下个断点处; - 获取、设置寄存器信息; - 获取、设置内存信息; - 对表达式进行估值计算; - 调用函数; - 其他; 本书后续章节会介绍如何实现上述操作,如果对调试器内部工作原理好奇,那就请继续吧。 > ps:go被广泛应用于微服务开发,如何在微服务架构下方便地对微服务进行调试呢?如果是一个单体应用,我们还可以通过跟踪多个线程、协程的状态来了解全貌,那如果当处理过程被分解到了多个不同的微服务上时,又如何通过调试器来完成整个处理过程的调试呢? > > 对于线上服务,这种方式可应用的空间会比较小,因为其会对性能影响比较大,且容器平台必须放开调试相关的安全设置,这意味着可能存在更多的安全风险。通过opentelemetry来观测线上服务的metrics、logging、tracing数据应该更有效。 > > 但是对开发阶段的服务,那调试器这种方式就有比较大的优势了,opentelemetry等解决方案存在较明显的延迟,对于及时调试并不算一种很好的解决方案。如果能在一个单点完成对整个微服务上下游的调试,那就太棒了。能做到吗?solo.io打造了 [**squash**](https://squash.solo.io/) 这款微服务架构下的调试器。 > > 本书最后,也会简单解释下squash的实现思路,看看别人是怎么做到的 :) ================================================ FILE: book/4-basics/2-dependencies.md ================================================ ## 4.2 依赖(支持) ### 4.2.1 调试符号信息 编译器、链接器根据源代码构建可执行程序,可执行程序中的数据是面向机器的,而非面向人类的。调试器如何理解可执行程序中的数据,并在机器表示和人类可读形式之间进行转换呢?这就需要调试信息的支持了。 当编译器将源代码转换成目标文件的时候,编译器会生成一些调试信息并将其存储到可执行程序中特殊的section中。当链接器将多个目标对象文件链接成一个完整的可执行程序的时候,链接器会将分散在不同目标文件中的调试信息进行合并后存储。 ![img](assets/clip_image001.png) 这里的调试信息如何生成、编解码、存储等是有相应的调试信息标准(如DWARF)指导的,调试信息标准指导编译器、链接器、调试器之间如何进行协作。编译器、链接器生成这些调试信息并将其存储到可执行程序的sections中,调试器会从中提取、解析与调试相关的信息,然后就可以构建起源码层面的视图。进而,调试器可以完成内存地址、指令地址、源码之间的相互映射。 不同的目标文件格式,调试符号信息可能会存储在不同的地方,一般可能有两种存储方式: #### 4.2.1.1 存储在目标文件自身 例如,[ELF](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format) 文件格式包含了DWARF调试信息对应的section,一般以".debug”或”.zdebug”开头。.debug前缀开头的section表示数据未压缩,.zdebug前缀开头的section表示数据经过了压缩。 > 这里给个实例,__debug_bin是一个由 `dlv debug`生成的可执行程序,包含了调试符号信息,`readelf`可以用来读取ELF文件中的section header,下面我们看一下ELF文件中包含的调试信息相关的 section。 > > go build可以通过指定链接器选项“**-ldflags=-compressdwarf=false**”来禁用压缩,提前了解这点,方便以后通过dwarfdump等工具分析理解dwarf调试信息如何组织非常有用。 > > ```bash > [root@centos ~]# readelf -a __debug_bin | grep debug > [12] .zdebug_abbrev PROGBITS 0000000000599000 0017b000 > [13] .zdebug_line PROGBITS 0000000000599114 0017b114 > [14] .zdebug_frame PROGBITS 00000000005a9f37 0018bf37 > [15] .zdebug_pubnames PROGBITS 00000000005b11a8 001931a8 > [16] .zdebug_pubtypes PROGBITS 00000000005b2fa0 00194fa0 > [17] .debug_gdb_script PROGBITS 00000000005b624b 0019824b > [18] .zdebug_info PROGBITS 00000000005b6273 00198273 > [19] .zdebug_loc PROGBITS 00000000005dcfe2 001befe2 > [20] .zdebug_ranges PROGBITS 00000000005e982d 001cb82d > ``` > > ps: 作者开始写这本电子书的时候非常早,当时2018年还是用的1.13,现在这么多年过去,发生了很多变化。 > > - 首先,go1.13中确实是这样的,zlib压缩后写入.zdebug_ sections (see: https://github.com/golang/go/issues/11799#issuecomment-399564050); > - 由于没有一次性完成该电子书,后续go1.19中作者再次尝试运行已有代码、校对内容时,发现已经不会写入.zdebug_ sections了 (上述linker flag失效了); > - 截止到今天2025.2.14再次续写本书内容,继续求证后发现,go1.22中已经明确废弃了 `.zdebug_` sections,而是以 `.debug_` section中内容是否带有 SHM_COMPRESSED flag来确定是否开启了压缩。 > see: https://github.com/golang/go/issues/58254#issuecomment-1421624004 > see: https://sourcegraph.com/github.com/golang/go/-/commit/75136fc14c0d3ec64a2f6728e96fc86066d853c9 > > 所以,还是要尽快完成,很有可能go后续会从DWARF v4升级到v5,到时候又会引入更多变化。 #### 4.2.1.2 存储在独立的文件中 例如,Microsoft Visual C++ 2.0生成的调试信息存储在独立的**.PDB(Program Database)**文件中,macOS平台上构建的调试符号信息一般存储在独立的**.dSYM/Resources/DWARF/**目录中。 > 这里给个示例,在macOS 10.15上,通过“**gcc -g**”构建一个包含调试符号的可执行程序,我们看下它生成的调试信息是如何存储的: > > **file: main.c** > > ```cpp > #include > #include > > int main(int argc, char *argv[]) > { > return 0; > } > > ``` > > ```bash > $ gcc -g -o main main.c > $ ls > main main.c main.dSYM/ > $ tree main.dSYM > main.dSYM/ > └── Contents > ├── Info.plist > └── Resources > └── DWARF > └── main| > ``` 可以看到,macOS 10.15上,gcc将调试信息也存储到了独立的main.dSYM/目录。可以借助 `dwarfdump or splitdwarf`工具进行分析,可以参考这篇文章:https://blog.golang.org/debug-opt。 #### 4.2.1.3 调试信息有什么用呢 调试器利用调试信息能够将源码中的函数、变量映射为内存中的地址,也就意味着开发者可以直接对源码中函数、变量进行操作而不用关心具体的内存地址,这也是符号级调试器相比于指令级调试器的优势。 借助源码到内存地址的映射,符号级调试器就可以直接显示变量的值,如何做到的呢?因为变量标识符可以映射成程序的内存地址,此外,调试信息中还记录了该变量的数据类型信息,这里的类型信息告诉调试器该变量一共占用多少个字节、实际用了多少比特、应该解读成哪种数据类型,调试器就可正确解析内存数据,进而显示变量值。 ![img](assets/clip_image002.png) 这里的映射关系也包含从源代码语句与内存中代码段指令地址范围的映射,这个也是符号级调试器的优势。当希望单步执行一条语句的时候,调试器可以根据该语句对应的地址范围决定应该执行多少条指令后停下来。 ### 4.2.2 调试相关支持 除了调试符号信息,调试器还需要其他的一些支持,即调试基础设施,包括:调试中断、系统调用、解释器、调试用户界面(GUI或者命令行)。 #### 4.2.2.1 调试中断 所有的商用操作系统都提供了调试相关的hook机制,这里的hook机制通常是通过内核系统调用的形式实现。为什么要通过系统调用实现呢?调试器调试应用程序的时候,需要读、写程序的数据、指令,就涉及到访问系统内存中一些受保护的特殊数据结构,普通用户进程是无权访问的,只能借助内核提供的系统调用来代表用户进程操作。 相比之下有个例外,DOS操作系统是实模式操作系统,由于没有对内存做保护,你可以直接做任何事情。 #### 4.2.2.2 系统调用 现在,绝大多数操作系统都实现了内存保护模式,内存保护模式是多用户、多任务操作系统的根基。如果没有保护模式,根本就不存在所谓的安全。关于内存保护模式如何实现的,可以参考X86系列处理器的发展史。 与DOS相反,Windows、Linux以及BSD都实现了内存保护模式,这意味着如果你想在这些平台上开发一个调试器,就需要通过平台提供的系统调用来实现。 以Linux系统调用为例,调试器进程(tracer)可以通过 `ptrace(PTRACE_ATTACH…)` attach到一个被调试进程(tracee),然后操作系统内核会给tracee进程发送一个信号SIGSTOP,tracee进程就会停下来,tracer进程就可以通过 `waitpid(pid)`来等待tracee停止事件。当tracer进程感知到tracee进程停止执行之后,tracer进程就可以进一步通过 `ptrace`系统调用、配合其他ptrace参数 `PTRACE_GETREGS、PTRACE_SETREGS、PTRACE_PEEKDATA、PTRACE_POKEDATA等`来读写寄存器、内存数据、设置断点,通过PTRACE_SINGLESTEP、PTRACE_CONT等控制代码的执行等。 简单提下内存 "**保护模式**" 的实现,这样有助于理解为什么现在调试器一般通过操作系统 "**系统调用**" 来实现,比如Linux ptrace。 **实模式大致原理:** 这里以x86处理器发展史来简单说明下,8086处理器是实模式寻址,意味着你可以写个程序通过CS:IP来跳到任意指令地址执行指令,或者DS:Offset读写任意内存地址数据,这样就很不安全。 在Intel后续处理器上为了建立起内存保护模式,首先引入了特权级的概念,ring0~ring3(ring0权限最高),Linux中仅使用ring0、ring3这两个(区分内核态和用户态够用了)。然后又引入了GDT、LDT的概念,这个什么用呢,它们是个表结构,记录了一系列的内存区间以及访问这些内存位置所需要的特权级。在访问真正的内存区域之前,需要先查表检查特权级是否足够。 **阻止执行任意位置指令:** 实模式下的CS:IP直接可以计算后用来寻址,保护模式下不行,CS的含义已经变了,不再是代码段起始地址,它(CS部分位字段)变成了一个指向GDT、LDT中的索引,查GDT、LDT可以知道访问对应的内存区所需要的特权级信息。如果当前特权级(CS部分位字段)低于CS对应的GDT描表项中的特权级,则不能访问对应内存区。这样执行指令的时候,就不能够随意指定个地址去执行该位置的指令了。 **阻止读写任意位置数据:** 对于如何阻止读写任意位置的数据,这个问题可以通过类似的方式来做到,就不进一步展开了,感兴趣读者可以自己查阅资料。 关于80286实现内存保护模式的更多信息,可参考[protected mode basics by Robert Collins](http://www.rcollins.org/articles/pmbasics/tspec_a1_doc.html),我是基于《Linux源码情景分析》中关于保护模式的内容回忆来补充这部分信息的,Robert Collins还额外描述了中断情况下如何保证保护模式。 那保护模式下当我们希望执行tracee的指令、读写tracee的数据时,只能借助于操作系统提供的 "**系统调用**" 来完成这些任务。 > **扩展阅读**: > - 对与进程、线程的表示,建议了解下操作系统进程控制块PCB的概念以及Linux下taskstruct、GDT、LDT相关的知识。 > - Linux平台对SIGSTOP信号的处理,可以参考:[How does SIGSTOP work in Linux kernel?](https://stackoverflow.com/questions/31946854/how-does-sigstop-work-in-linux-kernel) #### 4.2.2.3 解释器 如果是调试一门解释型的语言,会简单的多,因为所有的调试基础设施都可以直接内建在解释器中。通过一个解释器,就可以无限制地访问执行引擎。所有的调试操作及其依赖的能力都是运行在用户空间而非内核空间,也就不需要借助系统调用了。没有什么东西是被隐藏的。所要做的就是增加扩展来处理断点、单步执行等操作。 Andreas Zeller在《软件调试》书中提到,解释型语言的调试器通常比编译型语言的调试器简单,因为解释型语言的执行过程是透明的,而编译型语言的执行过程是隐藏的。 >"Building a debugger for an interpreted language is much easier than for a compiled language... Since the interpreter already has full control over the execution, it can easily provide debugging features." **核心论点如下:** - **解释型语言(Interpreted Languages)**: 解释器本身就是程序的运行环境。它在执行每一行代码时,都保留了完整的符号表、变量名和源代码映射。调试器只需要向解释器查询当前状态即可。因此,Zeller 认为编写解释型语言的调试器“相对简单”(Much easier),因为执行过程对解释器来说是透明的。 - **编译型语言(Compiled Languages)**: 程序被翻译成了机器码,原本的变量名和结构在执行时已经消失了。调试器必须通过“调试信息”(如 DWARF 或 PDB 格式)这种复杂的辅助手段,强行将二进制状态映射回源代码。这种过程是“非自然的”,因为执行过程在硬件层面是隐藏的(Opaque)。 ps: Andreas Zeller 还维护了 https://debuggingbook.org/ 这个网站,提供了jupyter notebook这种可以交互式阅读的电子书,但是是建立在解释型语言、解释器基础上的。对这部分内容感兴趣的读者可以参考,也会有所收获。 #### 4.2.2.4 内核调试器 操作系统构建起严格的内存保护模式之后,要想调试内核本身,就得通过一种特殊类型的调试器。传统的用户模式下的调试器是不行的,因为内存保护模式(如段、页式管理的相关逻辑)阻止了用户态程序操作内核映像。 你需要一个内核调试器! 内核调试器,能够指挥、控制中央处理器(CPU),这样就可以通过单步执行、断点等操作对内核代码进行调试、检查。这意味着内核调试器必须能够避开内存保护模式机制,通常内核级调试器都是与操作系统内核镜像打包在一起的。有些厂商要实现自己的内核级调试器,也会考虑将调试器作为设备驱动、可加载的内核模块的方式来设计、开发。 **内核调试和用户程序调试有着明显不同,我们以打印一个内存变量为例简单说明下:** - 当打印这个变量时,不巧这个内存页面被操作系统换出到交换区了,如果我们在用户级调试器里面通过系统调用的形式ptrace(PTRACE_PEEKDATA...)操作系统会自动把这个换出的页面加回来,然后帮把数据读回来,很简单,我们甚至都没有感觉到这背后一连串的缺页处理发生过。 - 但是如果是内核级调试的话,内核级调试器需要调试内核的代码,一步步地,这样缺页处理这些问题也要一步步过,如果我们直接打印变量地址很可能是看不到值的,可能这只会触发一个缺页异常。 **另外,内核级调试场景的复杂性,也决定了适用的调试器或者说调试方式的多样性:** - kgdb:内核自带的远程源码级调试工具,常用于源码级别定位和单步调试内核。 - kdb:内核自带的交互式调试工具,可直接在内核环境下进行简单调试操作。 - gdb + QEMU:结合QEMU虚拟机使用gdb,可用于内核源码的功能开发与学习。 - JTAG/OpenOCD:硬件级的底层调试工具,适合芯片带板、Bootloader、裸机环境等调试场景。 - Crash/Kdump:用于生产环境异常时的内核崩溃分析(core dump分析)。 - eBPF/Ftrace:用于性能剖析、动态追踪、线上排查复杂问题等动态观测场景。 >**扩展阅读: > >- [kernel space debuggers in Linux](https://sysplay.github.io/books/LinuxDrivers/book/Content/Part10.html) >- [user mode debugging vs kernel mode debugging](https://stackoverflow.com/questions/32998218/is-there-ever-an-advantage-to-user-mode-debug-over-kernel-mode-debug#:~:text=in%20kernel%20mode.-,User%20mode%20debugging,you%20need%20to%20have%20really%20professional%20comprehension%20of%20all%20those%20topics.,-Conclusion) >- [kernel debugger internals](https://www.kernel.org/doc/html/v4.18/dev-tools/kgdb.html#kernel-debugger-internals) #### 4.2.2.5 调试器界面 调试关心的是程序的状态,不同的调试器为用户提供了不同的方式来查看程序的运行状态。某些调试器(如gdb)提供简单但一致的命令行界面,其他调试器可能会与GUI环境集成。 GUI调试器能够同时呈现和访问更多的机器状态信息,使用GUI调试器,您可以轻松地同时监视数十个程序元素。 另一方面,如果你正在开发跨平台的应用程序,则可能很难找到在所有平台上都能运行的GUI IDE,这个时候跨平台的命令行调试器相比GUI调试器来说就有优势了。命令行调试器可能没有精美的GUI接口,但在任何平台上其命令行操作和行为都是一样的。命令行调试器相比GUI调试器拥有更陡峭的学习曲线,但一旦掌握了,你就可以在不同平台以一致的方式调试你的应用程序。 ### 4.2.3 符号调试器 #### 4.2.3.1 程序断点 程序断点(breakpoint),指的是程序中的一个位置,当程序执行到该位置时能够停下来,以便调试人员观察程序状态。 下面对程序断点进行分类说明: - 从实现手段而言,程序断点可以分为“**软件断点**”和“**硬件断点**”,前者是通过机器指令来实现,后者是借助处理器提供的调试寄存器来实现。 - 从生成销毁方式、生命周期角度而言,程序断点又可以分为“**静态断点**”和“**动态断点**”,前者是在程序编译时就已经确定,后者是在程序运行时动态创建。 ##### 4.2.3.1.1 软件断点 vs. 硬件断点 **软件断点**: X86平台上创建软件断点可以通过指令 `int 3`来生成**0xCC**这个一字节机器指令来创建,处理器执行完0xCC之后会暂停当前正在执行的进程。 具体是如何执行的呢?int 3表示会触发3号中断,对应机器指令是0xCC,处理器执行完该指令后就会触发3号中断,对应的中断服务程序就在IDT[3]中(IDT,Interrupt Descriptor Table,中断描述表或中断向量表)。BIOS中提供的中断服务程序是16位的,了解过Linux如何构建32位、64位内存保护模式的话,就会明白Linux启动后,IDT[3]指向的其实是Linux内核提供的中断处理程序(Linux初始化会覆盖BIOS提供的16位中断服务程序的中断向量表),这里就是暂停执行当前tracee进程,并通知tracer进程tracee已暂停执行。 > ps: 严格意义上来说,中断更倾向于表示外设产生的事件,而异常则指处理器执行指令时生成的一些事件,比如除零exception、缺页fault、陷阱trap等,详见:https://linux-kernel-labs.github.io/refs/heads/master/lectures/interrupts.html。我们文中将中断作为了一个更宽泛的术语来使用,希望读者能明确这一点。 **硬件断点**: 以x86架构为例,提供了4个调试地址寄存器(DR0-DR3)和2个调试控制寄存器(DR6-DR7)来支持硬件断点。 当设置一个硬件断点时,需要执行如下操作: 1. 将断点地址写入某个未使用的DR0-DR3寄存器。 2. 在DR7寄存器中设置相应的控制位: - L0-L3位:启用对应的DR0-DR3断点(置1表示启用); - G0-G3位:全局启用对应断点(置1表示启用); - R/W0-R/W3位:设置断点类型; - 00:执行断点 - 01:数据写入断点 - 11:数据读写断点 - LEN0-LEN3位:设置监控的数据长度(1/2/4/8字节)。 那么,程序执行过程中访问了设置为硬件断点的地址时,硬件是如何检测到断点事件发生的?以及是谁来做出判断和响应? 实际执行时,由CPU本身负责检测断点命中。每当CPU执行一条指令涉及内存读写或指令执行时,处理器会自动比较即将访问的地址和已经配置在DR0-DR3中的硬件断点地址。当命中了硬件断点的匹配条件(如执行、读写等类型和监控长度),CPU会自动触发一个调试异常(通常为#DB,Debug Exception,异常向量1)。此时,CPU会将调试状态信息写入DR6寄存器,指明是哪个断点触发。系统内核中的异常处理程序会捕获这个异常,将控制权交给调试器或对应的异常处理流程,从而完成对硬件断点的捕捉和响应工作。 简而言之,是CPU在硬件级别自动不断检测已设置的断点条件,一旦发现断点被触发,就通过产生异常通知操作系统和调试器,无需程序员手动检查。 好,现在理解了如何创建“程序断点”后,我们继续讨论“静态断点”和“动态断点”的区别。 ##### 4.2.3.1.2 静态断点 vs. 动态断点 **静态断点**: 静态断点指的是,在程序开发或调试时,由开发者预先在程序的某些位置插入断点语句,比如在代码里嵌入 `int 0x3` 这样的指令用于触发断点(常见于C/C++等支持内联汇编的语言)。此类断点通常需要借助调试器配合使用:程序执行到该位置时会触发中断,调试器捕获到该事件后暂停程序,等待用户在调试器中手动输入命令进行调试。静态断点的生命周期一般与进程一致,如果需要在不同情况下有选择地触发静态断点,可以通过在代码中增加一些条件判断。 静态断点的缺点在于不够灵活,每次需要修改断点位置都要重新编译程序,调试流程相对繁琐。而动态断点则允许调试人员在不修改源代码的情况下,随时在运行的程序任意位置插入或移除断点,使用上更加灵活方便。 **动态断点**: 动态断点是指调试人员在程序运行过程中,根据需要动态插入的断点。借助动态断点,符号级调试器可以在源码层面实现如 gdb 的 next、step、finish、continue 等单步和控制流程操作。 与静态断点不同,静态断点的生命周期通常与进程一致,而动态断点则更加灵活。实际调试时,符号级调试器主要依靠动态断点来控制程序的执行。 以软件断点为例,动态断点的设置与移除大致流程如下: - 调试器定位到目标语句`statement`的首条机器指令,读取其首字节操作码; - 保存该操作码的第一个字节,并将其替换为 0xCC(即 int3 指令); - 被调试进程(tracee)执行到该指令并运行 0xCC 后,会触发断点异常,程序暂停,此时调试器可以检测寄存器、变量等状态; - 当调试器继续控制tracee执行时,会将 tracee 的 PC(程序计数器)减 1,并将 0xCC 处的字节还原为原先的操作码; - 最后,通知内核恢复 tracee 的运行,程序会继续执行直到下一个断点或其他调试事件发生; 我们通过下面的C语言语句进行下简单的说明: > ```c > total = total +value; > ``` 假定上述语句对应的汇编指令为: ![img](assets/clip_image003.png) 给上述语句设置一个动态断点,调试器首先获取statement对应的第一条指令的操作码的第一个字节0x8B,并将其替换为0xCC。当调试器遇到这个断点的时候,它执行完0xCC后就会停下来。等清除这个断点时,它会将这里的0xCC替换为原来的操作码数据0x8b,并将PC值从00007调整为00006,然后通知tracee恢复执行,tracee就可以执行statement `total = total + value` 对应的完整3条指令。 一旦上述语句对应的指令被执行了之后,调试器可以考虑是否要再次为该语句设置动态断点,如果不可能执行到上述语句了,就可以不设置了,但是如果还是会执行到就会设置动态断点,比如for循环体中语句设置断点,当你调试完一轮之后,还希望下次循环进入时再次让断点生效,调试器这种情况下就应该再次插入断点。 #### 4.2.3.2 单步执行 对指令级调试器(也称机器级调试器)而言,单步执行很简单:处理器只需执行下一条机器指令,然后将程序控制权返回给调试器。 对于符号调试器,此过程并不那么简单,因为高级编程语言中的单个语句通常会转换为多个机器级指令,而且不同源代码语句对应的机器指令数量也不同。 符号调试器如何插入动态断点呢?这将取决于单步执行的动作的类型,可分三种类型。 ##### 4.2.3.2.1 单步执行进入 (下一条语句) 当符号调试器单步执行函数调用时,如 `function(value)` ,调试器需要检查接下来的机器指令,确认是否是函数调用(CALL指令): - 如果不是,下一条指令操作码不是函数调用,则当普通断点处理即可,保存该操作码并将其替换为断点。 - 如果是,需要确定CALL指令实际要执行的函数体在内存中的地址,并在函数体开头指令处设置断点。 ![img](assets/clip_image004.png) >ps: 不一定是在函数体内第一条指令处设置断点,比如Go语言函数调用通常会涉及到栈分裂(stack splitting),会在函数体开头插入一些指令来处理栈空间。在栈分裂相关指令处设置断点就不太好,因为如果涉及到栈分裂这个断点会被命中两次,对于调试来说会有干扰。 ##### 4.2.3.2.2 单步执行跳出 (一个函数) 当符号级调试器退出函数(或例程)时,它将在函数的活动记录(调用栈信息)中查找返回地址。 然后,它将返回地址处机器指令的操作码保存,并用断点替换。 当程序恢复执行时,该例程将执行完剩余语句,并跳转到其返回地址。 然后回到返回地址处的下一条指令后,将命中断点,程序控制权将交还给调试器。 这样做的结果是,您可以使调试器从被调函数返回到调用该函数的代码上。 >ps: ABI函数调用约定相关知识,简单提一下的话,就是函数调用时会创建栈帧,并将参数、返回地址、寄存器信息保存入栈,局部变量在栈帧中分配等等,函数返回前销毁栈帧,程序则继续跳转到返回地址对应指令处继续执行。您可以参考相关资料了解更多细节。 ##### 4.2.3.2.3 单步执行跳过 (下一条语句) 当符号级调试器单步执行一条语句时,它将查询程序的调试信息以确定该语句在内存中的地址范围,一旦调试器确定了该语句的结束位置,它将保存该语句后的第一条机器指令的操作码,并将其替换为断点。 加断点的语句,tracee执行完该语句对应的所有机器指令之后,调试器才能重新获得程序控制。 ![img](assets/clip_image005.png) >ps: 在一个源代码行中,可能包含了一条语句,也可能是多条语句,甚至可能是一个for循环,那么在执行符号级调试器next时通常是希望跳转到下一行源码处,那就需要知道当前这行源码对应着多少机器指令,才好控制调试器应该在那个指令地址处添加断点。这个如何做到呢?需要依赖调试信息对源代码行对应的指令地址范围进行描述,后面介绍DWARF调试信息时会介绍。 ### 4.2.4 本节小结 本节简要介绍了调试信息的生成、存储和解析操作,概要性地讲述了不同类型调试器的特点,以及断点的工作原理。我们还以单步步进一条语句为例,说明了单步执行进入、跳出、跳过时断点设置的大致流程。由于篇幅和层次原因,本节未展开详细讲解,但为后续更深入理解调试器的实现和调试原理打下了基础。 ================================================ FILE: book/4-basics/3-countertactics.md ================================================ ## 4.3 反调试技术 只要付出足够的时间和精力,可以说任何程序都能被逆向。调试器使得理解程序逻辑更加方便了,对心怀恶意的软件逆向人员也不例外。防人之心不可无,君子也要采取战术给软件逆向增加点难度,使恶意工程师越痛苦越好以阻止或者延缓他们弄清程序的工作逻辑。 鉴于此,可以采取一些步骤,将使恶意工程师很难通过调试器窥视您的程序。 ### 4.3.1 系统调用 #### 4.3.1.1 Windows 某些操作系统提供了特殊的系统调用,能指示当前进程是否正在调试器的调试模式下执行。 例如,Windows KERNEL32.DLL导出了一个名为`IsDebuggerPresent()`的函数。 您可以包装一个chk()函数,函数体内使用该系统调用进行检查。 ![img](assets/clip_image002-3995693.png) ![img](assets/clip_image003-3995693.png) 该窍门是程序启动后立即调用chk(),让检查逻辑在逆向人员设置并执行到断点前先执行。 ![img](assets/clip_image004-3995693.png) 如果观测到调试器正在调试当前进程,则可以强制程序运行异常、做些诡异的逻辑,把正在调试的人绕晕。 调试器是个独特的工具,因为它使用户可以从中立的角度来观察程序。 通过插入类似chk的代码,可以迫使用户进入一个扭曲的量子宇宙,在该宇宙中,精心构造的诡异行为、输出,可以有效保护您的程序,避免或者延缓被逆向。 对调试器使用有经验的读者,很快就可以发现上述方法,并不总是可靠,有经验的调试人员可以通过gdb启动进程并在main、chk位置添加断点,待chk返回前篡改寄存器EAX中的值来绕过该检查。所以这个方法并不总是奏效,有可能将chk()低调行事隐藏在一堆看似正常的代码中,反而有助于戏弄调试人员。有没有更靠谱的做法呢? 我们也可以使用 `NtQueryInformationProcess` 系统调用来判断当前进程是否正在被调试,这是一个内核态的系统调用,可以获取进程的详细信息,包括是否正在被调试。这个检查逻辑可以在整个生命周期内持续进行,不容易被绕过,一旦检测到正在被调试,就可以立即退出程序。 ``` __kernel_entry NTSTATUS NtQueryInformationProcess( [in] HANDLE ProcessHandle, [in] PROCESSINFOCLASS ProcessInformationClass, [out] PVOID ProcessInformation, [in] ULONG ProcessInformationLength, [out, optional] PULONG ReturnLength ); ``` 可以指定PROCESSINFOCLASS ProcessInformationClass = ProcessBasicInformation,此时会返回一个PEB结构来描述当前进程是否正在被调试器调试。 see: https://learn.microsoft.com/en-us/windows/win32/api/winternl/nf-winternl-ntqueryinformationprocess #### 4.3.1.2 Linux 在Linux下,也有类似Windows的方式,通常可以借助“`/proc/self/status`”中的“`TracePid`”属性来判断是否有调试器正在调试当前进程。 下面是个示例,检查当前进程是否正在被调试。 **被调试程序:** ```go package main import "fmt" import "os" func main() { fmt.Println("vim-go, pid: %d", os.Getpid()) } ``` **执行调试操作:** ```bash $ dlv debug main.go dlv> b main.main dlv> c dlv> n dlv> n dlv> vim-go, pid: 746 ``` **检查TracePid:** ```bash cat /proc/746/status | grep TracePid TracePid: 688 cat /proc/688/cmdline dlv debug main.go ``` 现在我们可以判断出当前进程正在被pid=688的调试器进程调试,并且该调试器是dlv。 如果不希望程序被调试,就可以在检测到 `TracePid != 0` 时直接退出。我们可以将检查逻辑隐藏在一堆看似正常的代码中,并尽量在程序启动后立即执行,或者在整个生命周期内持续进行。这样就不容易被绕过。 #### 4.3.1.3 其他平台 其他平台下,应该也有对应的解决方法,读者感兴趣可以自行查阅相关资料。 ### 4.3.2 移除调试信息 使调试更加困难的一种简单方法是从程序中删除调试信息: - 通过设置集成开发环境、编译构建工具链来生成release版本而非debug版本,比如C/C++ 通过gcc/g++构建时需要显示 `-g` 选项来生成调试信息,生成release版本时可以去掉 `-g` 选项,必要时还可以开 `-O` 优化。 - 对于有些编程语言的编译器默认生成调试信息,比如Go,对于已经包含了调试信息的二进制,也可以通过后期剥离调试信息来达成,比如使用GNU的 `strip` 工具从ELF文件中删除.debug_ sections。 一些商业软件公司更喜欢这种做法,并能接受后续诊断过程中额外加载调试信息以及加载调试信息带来的性能影响,它允许销售工程师执行现场诊断。当售后工程师进行内部咨询时,他们要做的就是插入调试信息并启动调试器。 对于移除了调试信息的程序,如果尝试使用调试器调试它,因为没有源码相关的信息展示,调试人员将很难明白程序的状态、工作方式。往往看到gdb提示找不到调试符号“no debugging symbols found”,此时就会让很多调试人员望而却步、放弃调试。 ![img](assets/clip_image005-3995693.png) 但是,缺少调试符号并不能阻止所有人,一些反编译器可以将机器指令重铸为高级源代码,好消息是这些工具倾向于生成可读性较差的代码。 >ps: 但是现如今借助于AI的力量,情况可能有所变化。现在AI有能力抽取这些源代码来推断更合适的函数名,让代码更容易被理解。这个方向也值得关注和研究。 ### 4.3.3 代码加盐 如果内存占用不是大问题,并且您不介意对性能造成轻微影响,则阻止调试器的一种方法是定期在代码中添加不必要的语句。可以这么说,这使得尝试进行逆向工程的人更容易迷失。 这样,即使您在程序中附带了调试符号,也很难弄清正在发生的事情(尤其是如果您认为每个语句都有合法目的)。扰乱视听,让调试人员摸不着头脑,这样,我们就相对更安全。 >ps: 俗话说“道高一尺魔高一丈”,在真正的高手面前,“茅、盾、机、巧” 你知道、我也知道,经验和高敏感性能让高手察觉这部分代码的意图,排除掉它或者通过其他逐个击破的策略收敛到更少的关键代码。 ### 4.3.4 混合内存模型 有一些强大的调试器,例如SoftICE,可以在用户模式和内核模式之间轻松切换。但是,很少有调试器可以在两个不同的内存模型之间进行跳转。比较特殊地,Windows下就允许发生这种行为。在Windows上,这种现象通常称为“thunking”,它允许16位代码和32位代码进行混合。 以下描述了Windows中使用的改进技术: ![img](assets/clip_image006.png) 这种混合内存模型也给调试器调试增加了难度。 ### 4.3.5 本节小结 调试器确实是一个定位分析问题的好帮手,但是用在“坏人”手里也可能成为他们攻击正常程序的工具。因此,本节对反调试技术进行了介绍。反调试技术也是一种重要的阻止逆向分析、提高安全性的手段。反调试技术大大增加了逆向分析的难度,降低了逆向分析的成功率。尽管不能100%杜绝攻击者的调试行为,但是增加其难度,也是降低攻击成功率的一种手段。 ================================================ FILE: book/4-basics/README.md ================================================ # 调试器基础 先简要介绍下调试器相关的基础知识,包括: - **目的**, 我们为什么需要一个调试器? - **支持**, 支持调试操作需要哪些能力的支持? - **安全**, 调试会带来哪些安全风险,又该如何防范? ================================================ FILE: book/5-debugger-skeleton/1-debugger_skeleton.md ================================================ ## 设计一个调试器雏形 调试器需要支持哪些常见操作才能满足调试需求?本节先从功能性需求、非功能性需求分析角度切入,分析下接下来的调试器开发过程中要做什么、注意些什么。 本章还有个任务,要先搭建一个基础的调试器框架,方便扩展调试器命令、命令选项、命令参数、查看帮助信息、命令自动补全等。在此基础上,后续章节实现不同调试命令时,我们只需要添加子命令及对应的处理逻辑即可。 大家在理解了这个调试器雏形之后,后续阅读到相关调试动作的具体实现章节时,会自然联想到如何定位工程中对应的代码,也有益于我们后续章节内容组织、方便读者阅读。 ### 功能性需求 调试器需要支持哪些功能?大家联想下常见调试器的使用经历,这个是比较直观的: - 允许调试可执行程序、调试运行中进程、调试coredump文件; - 允许对golang代码自动编译构建、调试完成后清理临时构建产物; - 允许查看源码信息 **list**; - 允许对二进制文件进行反汇编 **disass**; - 允许在源码中添加断点 **breakpoint `file:lineno`**; - 允许在源码中添加条件断点 **breakpoint `file:lineno` if `expr`**; - 允许逐语句执行 **next**; - 允许逐指令执行(也能允许进入函数)**step**; - 允许从function退出 **finish**; - 允许显示变量信息、寄存器信息 **print、display**; - 允许更新变量、寄存器信息 **set**; - 允许打印变量类型 **ptype**; - 允许对函数进行临时调用 **call**; - 允许查看调用堆栈信息 **bt**; - 允许选择调用栈中的特定栈帧 **frame**; - 允许查看goroutines列表、切换goroutine执行; - 允许查看threads列表、切换thread执行; **聪明人写代码时也会思考后续如何更加聪明地debug**,也因此对现代调试器提出了更多能力需求,比如: - 如何在“充满不确定性的环境”中实现“确定性调试”,`record & replay` 沉淀几十年,通过它我们能在程序中前进、后退并且允许重复地进行调试。 - 如何能够实现自动化调试,比如通过脚本、CI/CD流水线等自动化手段进行调试,现在 go-delve/delve 就支持了通过starlark脚本控制调试器执行自动化调试。 - 更进一步地,现在借助 LLM + 调试器相关的mcp ,也可以借助人工智能的力量进行调试分析。 在后续内容中,我们也会对这些高级调试技术进行介绍。 ### 非功能性需求 我们需要注重用户体验,即站在调试人员使用的角度,考虑如何让调试人员用的方便、调试的顺利。 对于一个调试器而言,因为我们会在各种任务间穿插切换,要灵活运行调试命令是必要的。一个基于命令行实现的调试器,要实现快速有效输入并不轻松。 - 首先调试器有很多调试命令,记住这些命令是有学习成本的,基于命令行的调试器会比基于GUI的调试器学习曲线更陡; - 基于命令行的调试器,其UI基于终端的文本模式进行显示,而非图形模式,这意味着它不能像GUI界面一样非常灵活方便地展示多种信息,如同时显示源码、断点、变量、寄存器、调用栈信息等; - 基于命令行的调试器需考虑调试命令输入效率的问题,比如输入命令以及对应的参数。GUI调试器在源码某行处添加一个断点通常是很简单的事情,鼠标点一下即可,但基于命令行的调试器则需要用户显示提供一个源码位置,如"break main.go:15",或者"break main.main"; - 调试器诸多调试命令,需要考虑自动补全命令、自动补全参数,如果支持别名,将会是一个不错的选项。调试器还需要记忆上次刚使用过的调试命令,以方便重复使用,例如频繁地逐语句执行命令序列 ,可以通过命令序列 代替,回车键默认使用上次的命令,这样对用户来说更方便; - 调试器有多种启动方式,对应多个启动命令,如`godbg exec `、`godbg debug `、`godbg attach `、`godbg core `,各自有不同的参数。此外调试器也有多种交互式的调试命令,如`break `、`break if `等,各自也有不同的参数。如何高效、合理地管理这些命令是一个需要考虑的事情; - 好的产品塑造用户习惯,但用户更懂用户自己,一个可配置化的调试器是比较合适的,如允许用户自定义命令的别名,等等; - 调试器本身,可能需要考虑未来的应用情况,其是否具备足够的适应性以在各种应用场景中使用,如能否在GoLand、VSCode等IDE中使用,或者可能的远程调试场景等。这些也对调试器本身的软件架构设计提出了要求; - 可扩展性,除了使用的便利性,也要考虑其未来的扩展性,如何支持一门新的编程语言,如何支持采用不同调试信息标准的程序调试,如何便利地与其他开发工具集成; - 健壮性、正确性,如何保证调试器本身的健壮性、正确性,可以借助代码评审、自动化测试、提高测试覆盖率等手段来改进; ### 本节小结 本节我们从调试器的功能性需求和非功能性需求两个维度,梳理了现代调试器应当具备的核心能力。功能性需求方面,调试器需要支持多种调试对象(可执行程序、运行中进程、coredump)、丰富的调试操作(断点、单步、查看变量、调用栈等),以及更高级的特性(如record and replay)。非功能性需求方面,则强调了用户体验、命令管理、自动补全、可配置性、可扩展性、健壮性等对调试器产品化的重要影响。 这些需求的梳理让我们认识到了现代调试器所面临的挑战,也为后续调试器架构设计和实现方案的选择奠定了基础。下一节我们将探讨调试器的架构设计,以及如何通过合理的技术方案满足上述需求,打造一个易用、可扩展的调试器。 ================================================ FILE: book/5-debugger-skeleton/2-debugger_solution.md ================================================ ## 架构设计和技术方案选择 ### 整体架构设计 调试器应该具备良好的扩展性设计,以支持在不同应用场景中的应用,如在命令行中调试,与不同的IDE VsCode、Goland进行集成,支持远程调试。这就要求调试器必须实现 “**frontend**”和“**backend**” 前后端分离式架构。 比如,我们可能在一台macOS机器上调试运行在Linux机器上的进程,此时frontend可以是运行在darwin/amd64机器上的VsCode或者命令行调试器,而backend可以是运行在linux/amd64 or linux/arm64机器上的调试器服务(被调试进程则与backend运行在同一台机器上)。 #### 最基础的三层架构 一个最基础的调试器,通常至少需要包含以下三层: ![debugger-arch-1](assets/debugger-arch-1.png) - **UI层 (UI layer)**:负责与用户交互,接收用户输入、展示调试信息(如变量、堆栈等)。将UI层单独分离,可以让用户交互逻辑与核心调试逻辑解耦,便于后续更换或支持不同的用户界面(如命令行、GUI、IDE插件等)。 - **符号层 (Symbolic Layer)**:负责解析和管理符号信息(如变量名、函数名、源码位置与内存地址的映射等),是调试器的核心桥梁,连接用户操作与底层调试逻辑。分离符号层有助于支持多种编程语言和不同的调试信息格式。 - **目标层 (Target Layer)**:直接与被调试程序(target)交互,负责进程控制、数据读写、断点设置、单步执行、内存和寄存器访问等。目标层的独立,使得调试器可以更容易适配不同的操作系统和硬件架构。 >ps:第6章《指令级调试器开发》将采用这种最基础的三层架构设计,帮助大家掌握这种最基础但也是最核心的设计实现。 #### 前后端分离式架构 上述三层架构设计是调试器不可或缺的核心部分,但是考虑到实际应用场景的多样性和复杂性,三层架构设计还远远不够。比如,如何支持前后端分离、远程调试、服务化、分布式调试等高级需求?还需要引入更细腻的架构层面的设计来满足这些扩展性和灵活性需求。 这里在三层架构的基础上,引入 **服务层(Service Layer)**,以实现调试器的前后端分离架构,如下所示: ![debugger-arch-2](assets/debugger-arch-2.png) - **前端 (frontend)**: 聚焦于与用户的交互逻辑,完成调试动作的触发、结果的展示; - **后端 (backend)**: 聚焦于目标进程、平台特性相关的底层实现,接收frontend的调试命令,并返回对应的结果,以在frontend进行展示; - **服务层 (service layer)**: 服务层是frontend和backend之间通信的桥梁,借助 **RPC (Remote Procedure Call)** 进行通信。 通过引入服务层,调试器的前后端可以实现解耦,前端专注于用户交互和命令管理,后端专注于与被调试进程的底层交互。服务层则负责命令的转发、数据的序列化与反序列化、状态同步等工作。这样一来,调试器不仅可以支持本地调试,还可以很容易地扩展为远程调试、分布式调试等场景。例如,前端可以是命令行工具、IDE插件,甚至是Web界面,而后端则可以部署在本地或远程服务器上,二者通过RPC(如gRPC、JSON-RPC等)进行通信。 这种架构设计极大提升了调试器的灵活性和可扩展性。无论是支持多种用户界面,还是适配不同的操作系统和硬件平台,亦或是实现多用户协作调试、云端调试等高级功能,都变得更加容易。同时,前后端分离也有助于团队协作开发,前端和后端可以并行开发、独立演进。 前后端分离式架构是现代调试器实现的主流趋势,也是满足复杂应用场景和未来扩展需求的坚实基础。 >ps:第9章《符号级调试器开发》,我们将采用这里的前后端分离式架构,以帮助读者掌握现代调试器架构设计及实现。 ### 调试命令管理 对于一个命令行调试器,涉及到多种启动调试的命令,在调试会话中也需要多种多样的调试命令,这些调试命令驱动着一个高效的调试过程,直到我们定位到问题源头。比如启动调试就可能多种方式,`godbg ...`,在调试会话中也涉及到大量调试命令,如 `break, condition, continue, next, step, stepin, stepout, finish, bt, args, loals` 等等,如何对这些调试命令进行有效地管理和扩展是一个挑战。 #### spf13/cobra go标准库支持flags,方便对命令行选项进行解析,但是和我们想要的能力比起来,还是差点意思。所以社区里也成长起一些非常优秀的命令行开发支持项目距,比如 [spf13/cobra](https://github.com/spf13/cobra),它是一个基于golang的开源的命令行程序开发框架,它具有如下特点: - 支持快速添加cmd; - 支持为指定cmd添加subcmd; - 支持帮助信息汇总展示; - 支持POSIX风格的参数解析; - 支持常见数据类型的参数解析; - 支持为cmd指定必要参数; - 支持生成shell自动补全脚本; - 等等; 可以说,cobra是一个非常优秀的命令行程序开发框架,在诸多大型开源项目中得以应用,如kubernetes、hugo、github-cli gh,等等。在我的个人项目中,也有不少是采用了cobra来对命令、子命令进行管理。 #### 命令分组 使用cobra对调试命令进行管理,将给我们带来很大的便利。对于`godbg exec `、`godbg attach `类似的命令及选项管理,cobra绰绰有余,使用默认的设置就可以提供很好的支持。 调试器除了上述“启动调试”相关的命令以外,也有很多“调试会话”中使用的调试命令,如断点相关的,调用栈相关的,查看源码、变量、寄存器等相关的。为了方便调试会话中中查看调试命令的帮助信息,对这些调试命令进行必要的分组是非常有必要的 (调试人员如果不能借助分组快速找到急需的调试命令,就会打断需要高度集中注意力的调试活动)。 比如: - break、condition、clear、toggle、on,这几个与增删激活断点以及命中后处理强相关,可以将它们归类到分组“**[breakpoint]**”; - print、display、args、locals、funcs、types、list,这几个与查看变量、参数、函数、类型、源码强相关,可以将它们归类到分组“**[show]**”; - backtrace、frame,这几个与查看调用栈、切换调用栈强相关,可以将它们归类到分组“**[frames]**”; - restart、continue、stepin、stepout、finish,这几个与运行强相关,可以将它们归类到分组“**[run]**”。 - ... - 其他调试命令及分组; cobra为每个命令提供了一个属性cobra.Command.Annotations,它是一个map类型,可以为每个命令添加一些kv属性信息,然后基于此可以对其进行一些分组等自定义的操作: ```go breakCmd.Annotation["group"] = "breakpoint" clearCmd.Annotation["group"] = "breakpoint" printCmd.Annotation["group"] = "show" frameCmd.Annotation["group"] = "frames" ``` 上面我们对几个命令根据功能进行了分组,假如我们用debugRootCmd表示最顶层的命令,那么我们可以自定义debugRootCmd的Use方法,方法内部我们遍历所有的子命令,并根据它们的属性Annotation["group"]进行分组后,再显示帮助信息。 查看帮助信息时将得到如下分组后的展示样式(而非默认列表样式),更便利、更有条理: ```bash [breakpoint] break : break ,添加断点 clear : clear ,清除断点 [show] print : print ,显示变量信息 [frames] frame : frame ,选择对应的栈帧 ``` 综上不管是调试器启动时的命令,还是调试会话中需要交互式键入的调试命令,都可以安心地使用cobra来完成,cobra能很好地满足我们的开发需求。 ### 其他易用性方案 需求分析阶段列出了一些要支持的调试操作,每个操作基本都需要一个或几个调试命令来支持,而每个调试命令又包含不同的选项。考虑到我们最终交付的是一个命令行调试器,命令行调试器尽管有它的优点,但是缺点也显而易见。 对使用者而言,要记住这么多调试命令、调试选项,还要正确输入它们以及它们的取值,会是一个巨大挑战。为了方便使用者,我们需要考虑一些非常必要的易用性方案设计。 > ps: 读者可能有疑问,既然命令行调试器用起来不方便,那为什么不提供一个GUI界面呢? > 考虑到在不同软硬件平台的可移植性、操作的一致性、go技术栈以及最终实现的工作量,我们更倾向于提供一个命令行版本的调试器。实际上,当我们掌握了命令行调试器之后,攀登过那陡峭的学习曲线这时候,你也会获得巨大的收益,你可以以一致的调试界面、调试命令、调试习惯在不同软硬件平台上进行调试。 #### 输入补全:启动命令选项 在调试过程中,我们很可能会遗忘命令名和选项名,或者需要高频输入它们,或者很容易输入错误,对于特定类型的选项、参数的值,也可能比较难输入,比如输入一个源文件的位置。此时,就会中断调试会话,这是一个很低效的过程。试想下,我们不得不执行help命令查看帮助信息,帮助信息将污染我们的调试会话,使得我们注意力被分散。所以作为一个调试器产品的设计者、开发者,应该对“查看帮助”信息的需求进行进一步挖掘。 用户是需要查看帮助信息,但是并不一定是通过help的形式,我们可以在他输入命令的同时就给予辅助输入的提示信息,自动补全就是不错的方法。 自动补全大家并不陌生,我们在shell里面使用的很多命令有自动补全的功能,包括 spf13/cobra 开发的命令行应用程序本身也支持生成shell的自动完成脚本(导入即可实现自动补全功能)。 ```bash godbg completion bash > ~/.bash_godbg source ~/.bash_godbg ``` 然后我们可以执行命令并通过TAB来触发自动补全,如 `godbg att` 会被自动补全为 `godbg attach`。 #### 输入补全:会话命令选项 这里借助spf13/cobra只可以解决启动调试时的命令和选项的自动补全,但是还解决不了调试器进程启动后调试会话内的自动补全。 - [go-prompt](https://github.com/c-bata/go-prompt)是一个不错的自动补全的库,它能够在程序运行期间根据用户输入自动给出自动补全的候选列表,并且支持多种选项设置,如候选列表的颜色、选中列表项的颜色等等。可以说,go-prompt是一个非常不错的选择。但是它的命令管理不如spf13/cobra方便,实际上它也可以和cobra结合使用。 - [cobra-prompt](https://github.com/stromland/cobra-prompt) 就是来解决这个问题的,它将go-prompt和spf13/cobra进行了一个比较好的集成,既能利用cobra的命令管理,也能发挥go-prompt的自动补全优势。cobra-prompt的实现原理很简单,将go-prompt获得的用户输入适当处理后,转给cobra debugRootCmd进行处理就可以。 - [liner](https://github.com/peterh/liner) 本书第6章提供的指令级调试器实现,最初采用了cobra-prompt进行开发,但是最终使用了liner进行代替,因为cobra-prompt的自动补全功能会经常干扰调试会话信息的连贯性,不一定真的有实质性的帮助,所以最后我们替换为了liner代替。最后,使用liner读取用户键入的调试命令,并通过cobra的命令管理来执行调试动作。简言之,我们仍然具备自动补全能力,只是放弃了go-prompt似的自动补全方式。 ps: 这里我们只对调试会话内的命令进行了输入自动补全,对于命令选项,则没有进行支持。调试器使用者可以通过 `help ` 查看相应选项及帮助信息。 #### 输入补全:其他输入信息 输入补全,我们借助spf13/cobra生成的completion脚本可以解决启动调试相关命令选项的输入补全,通过liner可以解决调试会话中命令的输入补全,但是这就够了吗? 对于后续还需要输入的参数值,比如 `break main.` ,此时我们想在main中某个函数处添加断点,但是我们此时希望借助TAB来自动补全输入函数名。spf13/cobra是做不到这点的,如果希望支持类似能力,就还需要进一步探索,这点也可以借助liner来完成。 理论上,我们为每个调试命令设置一个专用的completer(类似spf13/cobra那样),每个completer可以负责自动补全该调试命令的命令名、选项名,以及对应的值。比如 `break` 命令对应的completer就可以自动提取不同package里定义的funcs,然后根据用户输入进行过滤,并进行补全。 ps:这部分内容权当一种“愿景”了,我们不一定真的实现,但是这么做可能会让调试活动更加简单高效,读者也可以思考下有没有更好的方式。 ### 其他可扩展性支持 除了前后端分离和分层架构,调试器在设计和实现时还应考虑以下可扩展性支持: - **多语言支持**:调试器不仅要支持Go语言,还应具备扩展到其他语言(如C/C++、Rust等)的能力。这要求符号层和目标层的实现要有良好的抽象,便于适配不同语言的调试信息格式(如DWARF、PDB等)和运行时特性。 - **插件机制**:通过插件机制,调试器可以灵活地扩展新功能。例如,可以为不同的调试命令、表达式求值器、UI组件等提供插件接口,用户或第三方开发者可根据需要动态加载和卸载插件,增强调试器的功能。 - **脚本化与自动化**:支持脚本语言(如Python、Lua等)集成,允许用户编写脚本自动化调试流程、批量设置断点、批量分析变量等。这对于复杂调试场景和大规模问题定位非常有帮助。 - **远程与分布式调试**:除了本地调试,还应支持远程调试和分布式系统的调试。调试器需要具备跨网络通信、认证授权、数据加密等能力,能够安全高效地调试云端、容器、Kubernetes集群等环境中的进程。 - **多用户协作调试**:在某些场景下,多个开发者可能需要协同调试同一个进程。调试器可以设计为支持多用户会话、权限管理、调试状态同步等协作特性,提升团队效率。 - **可配置性与个性化**:调试器应允许用户自定义命令别名、快捷键、UI主题、命令分组等,满足不同用户的使用习惯和偏好。 - **与其他开发工具集成**:调试器应易于与IDE、CI/CD系统、性能分析工具等集成。例如,提供API、命令行接口、事件通知等机制,方便与外部工具协作。 - **健壮性与可测试性**:为保证调试器的健壮性和正确性,应支持单元测试、集成测试、回归测试等自动化测试机制,并具备良好的错误处理和日志记录能力,便于问题定位和维护。 通过上述多维度的可扩展性设计,调试器能够具备持续演进和扩展的能力,更好地适应不断变化的调试场景。在进行到后续章节、介绍完必要前置知识时,我们会继续对这些内容进行展开。 ### 本节小结 本节我们围绕调试器的架构设计与技术方案选择进行了系统梳理。首先介绍了调试器的基础三层架构(UI层、符号层、目标层),并进一步探讨了前后端分离式架构如何提升调试器的灵活性和可扩展性。随后,我们分析了调试命令的管理方式,以及命令行和调试会话中的自动补全技术选型与实现思路。最后,列举了其他多维度的可扩展性设计要点。 通过本节内容,读者可以对现代调试器的整体架构、关键技术选型及未来可扩展方向有一个全面的认识。接下来,我们将以具体的实现为例,逐步带领大家开发一个具备基础调试能力的指令级调试器,深入理解调试器各层的实际落地方式与工程细节。 ================================================ FILE: book/5-debugger-skeleton/3-debugger_solution_final.md ================================================ ## 具体的实现方案选择 ### 确定方案 对于业界主流的调试器实现,一般都会将其分为frontend、backend,二者通过service层进行通信,gdb、delve等等,无一例外。 本书提供的调试器实现,是从普及调试器设计实现角度出发,我们实际上提供了两个版本的调试器实现。 - 指令级调试器实现:它是一个基于UI层、符号层、目标层3层架构的简易调试器实现,实现思路已体现在了本节各部分描述中。本书提供的配套的完整版指令级调试器实现的源码地址为: [hitzhangjie/golang-debugger-lessons/0-godbg](https://github.com/hitzhangjie/godbg) or [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg)。为了方便大家按照章节循序渐进地学习,本书也提供了按照章节组织、循序渐进地开发调试器的代码示例,其源码地址为: [hitzhangjie/golang-debugger-lessons](https://github.com/debugger101/golang-debugger-lessons)。 - 符号级调试器实现:最初希望从头编写或者讲指令级调试器版本演变成一个符号级调试器版本作为教学示例,但是开发工作量较大,要想达到一个可用标准的版本工作量就更大。所以最后倾向于裁剪go-delve/delve以用更少的代码、更少的时间来完成符号级调试部分,把最核心的设计实现要点呈现给大家。对应的源码地址为 [hitzhangjie/tinydbg](https://github.com/hitzhangjie/tinydbg)。 ps:也许我们提供的两个版本的调试器,中间可能有点“跳跃”,是的,但是这样也并不是没有好处。一个是指令级调试器,非常精简,适合读者了解底层原理;一个是裁剪后的go-delve/delve,功能相对完整,适合读者了解现代调试器应具备的方方面面。作者最终选择裁剪go-delve/delve,工作量是一方面原因,再一个就是有go-delve/delve背书读者学完后也可以真正建立起调试器开发这个领域的“信心”。 ### 工作量评估 读者可能以为裁剪 go-delve/delve 会变得很简单,也对也不对: - 如果要达到对go语言程序调试完全可用,方便大家系统性学习、测试,从0到1开发工作量本身就会很大。我们搞个极度简化版的,作为教学目的作用也不大,大家学完之后还是会认为自己是个250,只知道点皮毛,而不会建立起那种“我能行”“我可以”的信心。所以要从0到1开发一个符合作者意愿的版本,开发工作量是非常大的。从这点来说,从已经发布的版本中筛选一个版本进行裁剪,和从头开发一个相比,工作量会一点; - 但是,要知道 go-delve/delve 是一个10多年来不断进行更新的项目,go语言在演进、DWARF调试信息也在演进、delve也在演进,这里的工作量“基数”摆在这,即便是像作者这样先裁剪、再重构优化也是个工作量非常大的工作。在此基础上,还要将过去几十年来调试领域的探索在delve中的实践进行系统性总结,比如 DWARF调试信息如何描述不同程序构造、状态,Mozilla RR 如何实现确定性重放,等等。 简而言之,即便是裁剪go-delve/delve,工作量也非常大。 欢迎读者朋友们下载体验,如您发现有问题,或者有更好的建议,欢迎请在本书项目issues中留言 :) 。 > ps: shit! 这flag都不知立了多少次了,今天2025.2.18,过去1年《三角洲行动》上线攻坚,实在没有时间续更,今年上半年能完成吧! > ps:本电子书的更新断断停停,这几年中发生了很多事情,2023.8.6开始恢复更新。今年必须完成,:muscle: > 我准备用AI帮我翻译成英文版,也许可以吸引到一些同样感兴趣的贡献者。 ### 本节小结 本节我们对调试器实现方案进行了梳理和分析,介绍了指令级调试器和符号级调试器两种实现路径,并结合实际项目(如 godbg 和裁剪版 delve)讨论了各自的设计思路、适用场景及开发工作量。通过对比可以看出,指令级调试器适合入门和理解底层原理,而符号级调试器则更贴近实际应用需求,能够帮助读者建立起开发现代调试器的信心。 在明确了实现方案和工作量评估后,接下来我们将深入探讨调试器各个核心模块的设计与实现细节,帮助读者逐步掌握调试器开发的关键技术点。 ================================================ FILE: book/5-debugger-skeleton/4-debugger_demos.md ================================================ ## 功能演示和实现效果 结合前面的思考,我们初步实现了一个调试器的雏形 `godbg`,它大致包含了我们需要的交互能力。后面我们将在此基础上一步步实现指令级调试器、符号级调试器。 先看下godbg的执行效果,然后大致介绍下源码的组织方式,方便读者了解讲解的功能点对应代码的哪一部分,后续新增章节的内容、源码就很容易对应上了。 ps: 最开始作者也是想在此godbg基础上演化成符号级调试器,前几节也提过了,最终的符号级调试器demo是裁剪的go-delve/delve,就不再赘述了,ok! ### 运行效果 #### 调试器帮助信息 `godbg help`用于展示启动调试器时的使用帮助信息。我们可以看到它有几个子命令,attach、core、exec分别对应不同的启动调试器的方式,help用于查看godbg及上述几个调试命令的使用帮助信息。 ```bash $ godbg help godbg是一个go程序符号级调试器,它是以学习为目的驱动开发的调试器, 希望我们的工作可以为更多人打开一个认识计算机世界的大门,不谢! Usage: godbg [flags] godbg [command] Available Commands: attach 调试运行中进程 core 调试内核转储 exec 调试可执行程序 help Help about any command Flags: --config string config file (default is $HOME/.godbg.yaml) -h, --help help for godbg Use "godbg [command] --help" for more information about a command. ``` #### 调试器调试会话界面 当启动godbg之后,默认会以弹出提示列表的方式来列出调试器支持的命令信息,这个只会在godbg启动时展示一次,期间为了保证调试会话不被污染,在没有用户输入时是不会显示任何提示信息的。 调试器启动成功后,会通过“**godbg>**”来表示当前创建好的调试会话,我们在此调试会话中输入调试命令来完成对应的调试动作。 ![godbg prompt showAtStart](assets/godbg_prompt1.png) 以清除断点操作为例,clear是清除单个断点,clearall是清除所有的断点,当我们输入 `cl`时,可以匹配到 `clear`、`clearall`两个命令,开发人员可以通过 `tab`按键或者 `arrow-down`来在候选列表中移动,`enter`选中列表项。 ![godbg prompt commands](assets/godbg_prompt2.png) 再看一个命令参数层面自动补全的例子,以list查看源码命令为例,此时会返回进程中涉及到的源码信息,如此处有main.go helloworld.go,方便用户选择,调试时就更简单了。 ![godbg prompt suggestions](assets/godbg_prompt3.png) 这是关于调试会话界面的运行效果展示。 > NOTE: 有必要提及的是,当前小节在撰写时是基于cobra-prompt实现的调试器版本进行描述的,在后续开发中,我们移除了cobra-prompt的自动补全方式,转而采用对用户干扰更小的自动补全方式,文档中的描述暂未来得及更新。 > > 读者也不用过于担心,这点不一致还不至于给上手学习带来负担。后续,我们会基于最新版实现更新这里的交互界面。 #### 会话中显示帮助信息 调试器调试会话中支持多个调试命令,各调试命令的功能是什么,又如何使用呢? 在调试器内部运行帮助命令“**godbg> help**”,就可以列出调试器已经支持的所有命令及其功能说明,并且对这些命令按照功能进行了归类,如断点相关的命令break、clear、clearall全部放置在了分组“**[breakpoint]**”下面,代码相关的有list、disass全部放置在了“**[code]**”分组下面,控制流相关的有next、step、finish全部放在了“**[ctrlflow]**”下面,还有其他一些调试命令。 ```bash godbg> help interactive debugging commands [breakpoint] break : 在源码中添加断点 clear : 清除指定编号的断点 clearall : 清除所有的断点 [code] disass : 反汇编机器指令 list : 查看源码信息 [ctrlflow] finish : 退出当前函数 next : 执行一条语句 step : 执行一条指令 [information] bt : 打印调用栈信息 display : 始终显示变量或寄存器值 frame : 选择调用栈中栈帧 print : 打印变量或寄存器值 ptypes : 打印变量类型信息 set =: 设置变量或寄存器值 [other] exit : 结束调试会话 help [command] : Help about any command ``` 如果想详细了解某一个调试命令如何使用,可以运行“**godbg> help `cmd`**”,如想查看break命令的使用运行“**godbg> help break**”。 #### 会话中执行调试命令 这里以显示源码信息为例,来演示如何在调试会话中执行调试动作。调试会话中执行调试命令“**godbg> list main.go**”来显示main.go中的源码信息。 ```bash godbg> list main.go list codes in file ``` 我们试运行命令 `list main.go`发现输出了一行语句,并没有实际打印源代码出来。 别急,这就是我们提到过的,目前这还只是一个调试器的雏形,我们确实已经把该搭的架子搭起来了,接下来的章节,我们将一步步实现这里的各个命令,实现指令级调试器,再实现符号级调试器。 ### 代码实现 该调试器代码,详见:[golang-debugger-lessons/0_godbg](https://github.com/debugger101/golang-debugger-lessons/tree/master/0_godbg),现在大致看下实现。 #### 目录结构 godbg的源码目录结构如下所示,为了节省篇幅省略了部分文件条目: ```bash tree godbg godbg : 项目根目录 ├── LICENSE :版权信息 ├── cmd :调试器启动调试的命令 │   ├── root.go : rootCmd绑定了子命令core、exec、attach │   ├── attach.go │   ├── core.go │   ├── exec.go │   ├── debug :调试会话中可使用的调试命令 │   │   ├── root_debug.go : debugRootCmd绑定了众多调试会话调试命令 │   │   ├── backtrace.go │   │   ├── break.go │   │   ├── clear.go │   │   ├── clearall.go │   │   ├── disass.go │   │   ├── display.go │   │   ├── exit.go .... ├── go.mod ├── go.sum ├── main.go :程序入口main.main ├── syms :符号层,用于实现指令地址和源码的映射、符号查询等等 └── target :target层,用于实现低级操作,如指令patch设置断点等等 ├── backtrace.go ├── breakpoint.go ├── call.go ├── continue.go ├── disassemble.go .... ``` 可见我们已经将大部分调试需要的命令给纳入进来了,只不过还没有实现,后续我们将一步步实现各个调试命令。命令实现的功能逻辑,可能会涉及到对应的 `${命令}.go`文件,以及符号层syms package、target层target package下的相关代码。 介绍完代码组织,后面讲解一个调试命令或者功能的实现时,读者应该可以方便快速地找到对应的实现代码。 #### 源码解析:命令管理逻辑 熟悉cobra编程的看完main.go就会知道该调试器是基于cobra进行命令管理的。 ```go package main import "godbg/cmd" func main() { cmd.Execute() } ``` godbg下各个子命令exec、debug、core分别对应cmd/exec.go、cmd/debug.go、cmd/core.go,它们都是cmd/root.go中定义的rootCmd的子命令。 ```go var rootCmd = &cobra.Command{ Use: "godbg", Short: "godbg是一个面向go语言的符号级调试器", Long: ` godbg是一个go程序符号级调试器,它是以学习为目的驱动开发的调试器, 希望我们的工作可以为更多人打开一个认识计算机世界的大门,不谢!`, // Uncomment the following line if your bare application // has an action associated with it: Run: func(cmd *cobra.Command, args []string) { // TODO comment out this, this should be enabled only in debugging phase debug.NewDebugShell().Run() }, } ``` 当我们执行godbg的时候,执行的 `rootCmd.Run()`逻辑,当我们执行 `godbg exec`的时候执行的则是 `execCmd.Run()`逻辑,这个很好理解,也很容易上手。 ```go var execCmd = &cobra.Command{ Use: "exec ", Short: "调试可执行程序", Long: `调试可执行程序`, Run: func(cmd *cobra.Command, args []string) { // TODO start process and attach fmt.Printf("exec %s\n", strings.Join(args, "")) debug.NewDebugShell().Run() }, } func init() { rootCmd.AddCommand(execCmd) } ``` 以上是 `godbg exec `时要执行的exec命令,它首先启动进程并attach到进程,准备就绪后,再启动一个调试会话,我们在调试会话中继续输入调试命令来进行调试。 #### 源码解析:调试会话调试命令管理 godbg/cmd/debug/root_debug.go中是使用cobra-prompt构建的一个命令管理器,它结合了cobra命令管理以及go-prompt的自动提示补全能力,非常适合管理命令多、命令选项多、命令候选参数多、命令使用频繁的场景,比如调试器会话中。 只需要执行 `debug.NewDebugShell().Run()`即可快速模拟一个调试会话的用户输入、执行处理、完成调试信息展示的逻辑。 ```go // NewDebugShell 创建一个debug专用的交互管理器 func NewDebugShell() *cobraprompt.CobraPrompt { fn := func() func(cmd *cobra.Command) error { return func(cmd *cobra.Command) error { usage := groupDebugCommands(cmd) fmt.Println(usage) return nil } } debugRootCmd.SetUsageFunc(fn()) return &cobraprompt.CobraPrompt{ RootCmd: debugRootCmd, DynamicSuggestionsFunc: dynamicSuggestions, ResetFlagsFlag: true, GoPromptOptions: []prompt.Option{ prompt.OptionTitle(description), prompt.OptionPrefix(prefix), prompt.OptionSuggestionBGColor(prompt.DarkBlue), prompt.OptionDescriptionBGColor(prompt.DarkBlue), prompt.OptionSelectedSuggestionBGColor(prompt.Red), prompt.OptionSelectedDescriptionBGColor(prompt.Red), // here, hide prompt dropdown list // TODO do we have a better way to show/hide the prompt dropdown list? prompt.OptionMaxSuggestion(10), prompt.OptionShowCompletionAtStart(), }, EnableSilentPrompt: true, EnableShowAtStart: true, } } ``` 关于自定义自动提示信息的实现,可以参考函数实现 `dynamicSuggestions(string, prompt.Document)`。 ```go func dynamicSuggestions(annotation string, _ prompt.Document) []prompt.Suggest { switch annotation { case suggestionListSourceFiles: return GetSourceFiles() default: return []prompt.Suggest{} } } // list 输入list时返回候选源文件名作为提示补全信息 func GetSourceFiles() []prompt.Suggest { return []prompt.Suggest{ {Text: "main.go", Description: "main.go"}, {Text: "helloworld.go", Description: "helloworld.go"}, } } ``` 需要注意的是cobra-prompt规定了cobra command只有添加了 ``的annotation项之后才会激发命令参数的自动补全逻辑。以list命令将源文件列表作为补全信息为例,list命令在Annotations这个map字段中添加了CALLBACK_ANNOTATION的kvpair。 ```go var listCmd = &cobra.Command{ Use: "list ", Short: "查看源码信息", Aliases: []string{"l"}, Annotations: map[string]string{ cmdGroupKey: cmdGroupSource, cobraprompt.CALLBACK_ANNOTATION: suggestionListSourceFiles, }, Run: func(cmd *cobra.Command, args []string) { fmt.Println("list codes in file") }, } ``` 调试会话中运行 `list main.go` ```bash godbg> list main.go list codes in file ``` 我们试运行命令 `list main.go`发现输出了一行语句,代表命令已经顺利执行了,我们后面会实现真实的展示源代码的功能。 现在,我们大致介绍了godbg的一个相对完整的骨架,相信读者朋友们已经跃跃欲试想进入下一步的开发了 :) 。 > Note:在该调试器demo的完整版实现中,详见 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中,我们已经彻底移除了cobraprompt,动态提示对调试会话干扰性有点大,在其他的非高频输入的命令行程序中使用更合适。 ### 本节小结 本节我们通过具体的演示和代码片段,展示了调试器雏形 godbg 的基本交互能力,包括命令行帮助信息、调试会话界面、命令及参数的自动补全、分组帮助等功能。我们详细介绍了如何利用 cobra-prompt 实现命令参数的动态补全,并结合实际代码说明了命令注册、补全回调的实现方式。通过这些内容,读者可以直观感受到调试器的基本使用体验和交互流程,为后续深入实现各个调试功能模块(如断点管理、单步执行、源码查看等)打下了坚实的基础。 接下来,我们将以此骨架为基础,逐步实现调试器的核心功能模块,深入剖析调试器各层的设计与实现细节,帮助大家系统掌握调试器开发的关键技术点。 ================================================ FILE: book/5-debugger-skeleton/README.md ================================================ ## 走进调试器开发 从本章开始,我们正式走进调试器开发的世界。 本章内容分为四个部分: - **需求分析**:从功能性需求和非功能性需求两个维度,分析现代调试器应该具备的核心能力 - **架构设计**:介绍调试器的分层架构设计,以及如何实现良好的扩展性和命令管理 - **实现方案**:确定具体的实现策略,包括指令级和符号级两个版本的调试器实现 - **功能演示**:通过实际运行效果,展示调试器的各项功能和交互体验 本章我们将从需求分析切入,探讨开发一个调试器时需关注的核心要点,随后逐步梳理调试器的技术架构,甄选合适的实现方案,最终实现一个能够运行的调试器雏形。 我们将在此基础上继续深入,逐步实现指令级调试、符号级调试,带领读者一步步理解调试器的工作原理和设计实现。 >本章结束后,读者也将掌握使用 [spf13/cobra](https://github.com/spf13/cobra) 进行命令行程序开发的一些技巧,如命令管理、选项管理、参数管理、help信息管理、如何实现自动补全等等。go语言经常用来开发一些使用的命令行工具,而spf13/cobra 就是当前最成功的一个项目,很多知名项目的命令行工具是基于它构建,比如 kubernetes、hugo、Github CLI 等等。 ================================================ FILE: book/6-develop-inst-debugger/1-process_start.md ================================================ ## 启动调试:启动进程 ### 实现目标:`godbg exec ` 启动新进程 调试器执行调试,首先得确定要调试的目标。它可能是一个进程实例,或者是一个core文件。为了便利性,调试器也可以代为执行编译操作,如 `dlv debug [main module] | [test package]`,会自动对main module或者test package进行编译构建。 我们先关注如何对一个运行中的进程进行调试,这是本章指令级调试部分的重点。core文件是为进程生成的运行时内核转储文件,包含了进程结束前的内存、硬件上下文信息。调试器可以分析core文件来了解当时的进程执行情况,如程序crash之前的调用栈信息。对运行中的进程可以执行的调试操作,覆盖了对core文件能执行的操作。所以本章优先介绍对运行中的进程进行调试,对core文件的调试支持(包括core文件构成、生成、调试)我们将在符号级调试部分再进行介绍。 调试一个进程,主要有以下几种情况: - 如果目标程序 ``已构建好,但是没有运行:我们需要启动程序并跟踪进程,如 `dlv exec `、`gdb `; - 如果目标程序还没有进行构建,要先构建然后运行:我们需要传递相关的编译选项,确保生成必要的调试信息、关闭编译优化,如 `dlv debug`、`dlv test`; dlv自动构建时会自动传递 `-gcflags="all=-N -l"`选项来禁用编译构建过程中的内联、优化,以保证构建产物满足调试器调试需要。 - 如果目标程序已经运行,且已经确认了进程pid:我们需要通过进程pid来跟踪进程,如 `dlv attach `、`gdb `; OK,我们先介绍第一种情况,启动构建好的程序并执行调试。 本节呢,我们先介绍如何启动一个目标程序,得到一个运行中的进程,等待程序执行结束,并获取运行结果。下一节我们再介绍如何启动并跟踪进程执行。 ### 基础知识 我们一步步实现指令级调试器godbg,首先为它添加第一个调试命令 `godbg exec `: - exec命令接收参数prog、启动程序prog并获取其执行结果; - prog参数为可执行程序的文件路径,或者一个可执行程序的名字,这个名字在 `$PATH` 搜索路径中可以搜索到。 在SHELL中只要键入可执行程序路径或者程序名就可以启动程序,在stdout、stderr获取程序运行时输出,并可以通过 `$?` 获取进程返回值。那么在Go编程中应该如何实现这些操作呢?Go标准库提供了 `os/exec` 包,允许指定程序路径、程序名来启动进程,并获取输出信息、执行结果。 通过 `cmd = exec.Command(...)`方法我们可以创建一个 `exec.Cmd` 实例: - 之后则可以通过 `err := cmd.Start()`方法来启动程序,继续执行 `err := cmd.Wait() 可以`等待进程结束,然后可以再获取结果; - 如果希望启动程序并一直等待到进程执行结束,也可以通过 `err := cmd.Run()` 来代替上述 `err := cmd.Start() + err := cmd.Wait()`; - 如果希望获取进程执行期间输出到stdout、stderr的信息,可以在启动前修改 `cmd.Stdout`、`cmd.Stderr` 指向一个bytes.Buffer收集起来; 如果您感觉上述操作比较复杂,想寻求更简化的操作实现,别急真的有: - 如果希望启动程序并等待执行结束,同时能获取stdout、stderr输出信息,可以通过 `buf, err := Cmd.CombineOutput()` 来完成。 其中err表示进程启动、执行期间是否出错,buf中记录了执行期间输出到stdout、stderr的信息: ```go package exec // import "os/exec" // Command 该方法接收可执行程序名称或者路径,arg是传递给可执行程序的参数信息, // 该函数返回一个Cmd对象,通过它来启动程序、获取程序执行结果等,注意参数name // 可以是一个可执行程序的路径,也可以是一个PATH中可以搜索到的可执行程序名 func Command(name string, arg ...string) *Cmd // Cmd 通过Cmd来执行程序、获取程序执行结果等等,Cmd一旦调用Start、Run等方法之 // 后就不能再复用了 type Cmd struct { ... } // CombinedOutput 返回程序执行时输出到stdout、stderr的信息 func (c *Cmd) CombinedOutput() ([]byte, error) // Output 返回程序执行时输出到stdout的信息,返回值列表中的error表示执行中遇到错误 func (c *Cmd) Output() ([]byte, error) // Run 启动程序并且等待程序执行结束,返回值列表中的error表示执行中遇到错误 func (c *Cmd) Run() error // Start 启动程序,但是不等待程序执行结束,返回值列表中的error表示执行中遇到错误 func (c *Cmd) Start() error ... // Wait 等待cmd执行结束,该方法必须与Start()方法配合使用,返回值error表示执行中遇到错误 // // Wait等待程序执行结束并获得程序的退出码(也就是返回值,os.Exit(?)将值返回给操作系统进而被父进程获取), // 并释放对应的资源(比如id资源,联想下PCB) func (c *Cmd) Wait() error ``` ### 代码实现 下面基于go标准库 `os/exec` package来演示如何启动程序创建进程实例。示例代码详见:golang-debugger-lessons/1_process_start。 file: main.go ```go package main import ( "fmt" "os" "os/exec" ) const ( usage = "Usage: ./godbg exec " cmdExec = "exec" ) func main() { if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "%s\n\n", usage) os.Exit(1) } cmd := os.Args[1] switch cmd { case cmdExec: prog := os.Args[2] progCmd := exec.Command(prog) buf, err := progCmd.CombinedOutput() if err != nil { fmt.Fprintf(os.Stderr, "%s exec error: %v, \n\n%s\n\n", err, string(buf)) os.Exit(1) } fmt.Fprintf(os.Stdout, "%s\n", string(buf)) default: fmt.Fprintf(os.Stderr, "%s unknown cmd\n\n", cmd) os.Exit(1) } } ``` 这里的程序逻辑比较简单: - 程序运行时,首先检查命令行参数, - `godbg exec `,`os.Args` 至少有3个参数,如果参数数量不对,直接报错退出; - 接下来校验第2个参数,如果 `os.Args[1]` 不是exec,也直接报错退出; - 参数正常情况下,第3个参数 `os.Args[2]` 应该是一个程序路径或者程序名,我们准备一个exec.Cmd对象,然后启动并获取运行结果; ### 代码测试 执行以下命令先完成程序构建,然后执行程序进行测试: ```bash 1_start-process $ GO111MODULE=off go build -o godbg main.go ./godbg exec ``` 后续随着功能越来越多,我们会分包分文件管理,此时就需要对整个module进行go build再测试,或者直接go install之后再测试。当前示例代码只有一个简单的源文件,`go run main.go` 就可以快速测试,如在目录golang-debugger-lessons/1_start-process下执行 `GO111MODULE=off go run main.go exec ls` 。 ```bash 1_start-process $ GO111MODULE=off go run main.go exec ls tracee pid: 270 main.go README.md ``` godbg正常执行了命令ls并显示出了当前目录下的文件 `main.go README.md` ,目标进程ls被正常执行了,并且我们成功获得了ls的执行结果。 ### 本节小结 本节我们学习了如何通过 Go 代码启动并执行一个外部进程,掌握了使用 `exec.Command` 创建和运行子进程的方法,并能够捕获其输出和错误信息。通过简单的命令行参数解析,实现了 `godbg exec ` 的基本功能,为后续实现调试器的进程控制和调试功能打下了基础。建议读者动手实践,熟悉进程启动和参数校验的流程,为后续章节的深入学习做好准备。 ================================================ FILE: book/6-develop-inst-debugger/10-clearall-atexit.md ================================================ ## 软件动态断点:退出前清理机制 ### 问题背景:断点残留的危害 在调试器开发过程中,我们通过ptrace系统调用对目标进程的指令进行动态修改来实现动态软件断点。具体来说,断点的添加是通过将目标地址的指令字节替换为0xCC(int3指令)来实现的,同时需要备份原始指令字节以便后续恢复。 调试器与tracee的关系存在多种场景: - 通过 `debug` 编译构建并启动构建好的程序; - 通过 `exec` 启动已经构建好的程序; - 通过 `attach`跟踪正在运行中的进程。 对于前两种情况,调试器退出时通常会主动终止tracee;而对于 `attach`场景,调试器退出后时一般会倾向于恢复tracee的执行。 然而,如果调试器在退出前没有主动清理这些动态添加的断点,将会给被调试进程造成严重的不良影响: 1. **指令不完整**:多字节指令被patch后变成不完整的指令序列 2. **SIGTRAP信号**:当tracee执行到断点位置时,会触发SIGTRAP信号 3. **进程终止**:在没有tracer的情况下,内核的默认行为是杀死该tracee进程 我们必须足够重视这个问题,需要实现一个类似C语言atexit的机制,在调试器退出前自动清理所有断点。 ### 实现目标:自动断点清理 为了避免上述问题,我们需要在调试器退出前自动清理所有已添加的断点。这要求我们: 1. **跟踪所有断点**:维护一个全局的断点集合,记录所有已添加的断点信息 2. **自动清理机制**:在调试会话结束时,自动遍历并清理所有断点 3. **优雅退出**:确保tracee进程在调试器退出后能够继续正常运行 ### 代码实现 我们通过实现一个类似C语言 `atexit`的机制来实现自动断点清理。具体实现包括: #### 1. DebugSession结构体扩展 ```go type DebugSession struct { // ... 其他字段 defers []func() // 退出前需要执行的清理函数 } // AtExit 注册退出前的清理函数 func (s *DebugSession) AtExit(fn func()) *DebugSession { s.defers = append(s.defers, fn) return s } ``` #### 2. 启动方法中的defer机制 ```go func (s *DebugSession) Start() { s.liner.SetCompleter(completer) s.liner.SetTabCompletionStyle(liner.TabPrints) // 注册退出前的清理逻辑 defer func() { for idx := len(s.defers) - 1; idx >= 0; idx-- { s.defers[idx]() } }() // ... 其他启动逻辑 } ``` #### 3. 断点清理函数 ```go // Cleanup 清理所有断点的函数 func Cleanup() { var ( dbp = target.DBPProcess err error ) // 先清理掉之前创建的断点 fmt.Printf("before detached, clearall created breakpoints.") if err := target.DBPProcess.ClearAll(); err != nil { fmt.Fprintf(os.Stderr, "clearall failed err: %v\n", err) } // 根据被调试进程创建的方式,debug、exec or attach,来决定如何做善后处理 // - debug: kill traced process, delete generated binary // - exec: kill traced process // - attach: detach traced process if err = dbp.Detach(); err != nil { fmt.Fprintf(os.Stderr, "detach tracee: %d, err: %v\n", dbp.Process.Pid, err) return } switch dbp.Kind { case target.DEBUG: fmt.Fprintf(os.Stdout, "tracee is is built and run by tracer, remove binary and kill it: %d\n", dbp.Kind) if err = os.RemoveAll(dbp.Command); err != nil { fmt.Fprintf(os.Stderr, "remove built binary %s, err: %v\n", dbp.Command, err) return } if err = syscall.Kill(dbp.Process.Pid, syscall.SIGKILL); err != nil { fmt.Fprintf(os.Stderr, "kill tracee: %d, err: %v\n", dbp.Process.Pid, err) return } fallthrough case target.EXEC: fmt.Fprintf(os.Stdout, "tracee is is run by tracer, kill it: %d\n", dbp.Kind) if err = syscall.Kill(dbp.Process.Pid, syscall.SIGKILL); err != nil { fmt.Fprintf(os.Stderr, "kill tracee: %d, err: %v\n", dbp.Process.Pid, err) return } default: fmt.Fprintf(os.Stdout, "tracee is an attached process, leave it running") } } ``` 特别注意的是 `target.DBPProcess.ClearAll()` 执行过程中,不光会还原断点处指令数据,还会rewind所有受影响的线程的PC,保证调试器退出后各个线程可以继续正常执行。同时考虑了attach、debug两种方式下对于tracee的不同处理,后者会杀死,前者会询问是否要继续执行。 #### 4. 调试会话的创建和使用 ```go // 创建调试会话并注册清理函数 session := debug.NewDebugSession().AtExit(Cleanup) session.Start() ``` ### 测试用例 #### 测试场景:不清理断点的后果 我们通过一个实际的测试来演示不清理断点会导致的问题: 1. **启动测试进程**: ```bash $ while [ 1 -eq 1 ]; do t=`date`; echo "$t pid: $$"; sleep 1; done Sun Sep 7 15:22:23 CST 2025 pid: 416728 Sun Sep 7 15:22:24 CST 2025 pid: 416728 Sun Sep 7 15:22:25 CST 2025 pid: 416728 Sun Sep 7 15:22:26 CST 2025 pid: 416728 ``` 2. **附加调试器并添加断点**: ```bash godbg attach 416728 godbg> pregs Rax Rbx ... Rip ... godbg> break godbg> continue ``` 3. **观察进程行为**: 执行continue后,tracee开始重新输出信息: ```bash Sun Sep 7 15:23:19 CST 2025 pid: 416728 <= after we run `continue`, we see the output again. ``` 4. **退出调试器(不清理断点)**: ```bash godbg> exit ``` 5. **观察进程终止**: ```bash Sun Sep 7 15:22:23 CST 2025 pid: 416728 Sun Sep 7 15:22:24 CST 2025 pid: 416728 Sun Sep 7 15:22:25 CST 2025 pid: 416728 Sun Sep 7 15:22:26 CST 2025 pid: 416728 Sun Sep 7 15:22:27 CST 2025 pid: 416728 Sun Sep 7 15:23:19 CST 2025 pid: 416728 [process exited with code 5 (0x00000005)] <= tracee exited with error You can now close this terminal with Ctrl+D, or press Enter to restart. ``` #### 测试场景:使用AtExit机制的正确行为 1. **启动测试进程**: ```bash $ while [ 1 -eq 1 ]; do t=`date`; echo "$t pid: $$"; sleep 1; done ``` 2. **使用带AtExit的调试器**: ```bash godbg attach 416728 godbg> break
godbg> exit 正在清理断点... 断点清理完成 ``` 3. **验证进程继续运行**: ```bash Sun Sep 7 15:22:23 CST 2025 pid: 416728 Sun Sep 7 15:22:24 CST 2025 pid: 416728 Sun Sep 7 15:22:25 CST 2025 pid: 416728 Sun Sep 7 15:22:26 CST 2025 pid: 416728 Sun Sep 7 15:22:27 CST 2025 pid: 416728 Sun Sep 7 15:23:19 CST 2025 pid: 416728 Sun Sep 7 15:23:20 CST 2025 pid: 416728 <= 进程继续正常运行 Sun Sep 7 15:23:21 CST 2025 pid: 416728 ``` ### 本节小结 本节主要探讨了调试器退出前断点清理机制的重要性与实现方法,核心内容包括:**断点残留的危害性分析**;**SIGTRAP信号导致的进程终止问题**;**AtExit机制的实现原理**;**自动断点清理的代码实现**。 本节核心要点包括: - 断点通过ptrace修改指令字节实现,如果不清理会导致多字节指令不完整,执行时触发SIGTRAP信号 - 在没有tracer的情况下,内核默认行为是杀死触发SIGTRAP的进程,导致被调试进程异常终止 - 通过实现类似C语言atexit的机制,在调试会话退出前自动清理所有断点,确保tracee进程能够继续正常运行 - AtExit机制通过defer函数和回调函数注册实现,提供了优雅的资源清理方式 本节内容为调试器的健壮性设计提供了重要保障,确保调试器在各种退出场景下都能正确清理资源,为读者理解调试器的生命周期管理和资源清理机制奠定了实践基础。通过本节的学习,读者可以掌握调试器开发中资源管理的最佳实践,为后续开发更复杂的调试器功能提供了重要的设计参考。 ================================================ FILE: book/6-develop-inst-debugger/11-step.md ================================================ ## 执行控制:逐指令执行 ### 实现目标:`godbg> step` 逐指令执行 在实现了反汇编以及添加移除断点功能后,我们将开始进一步探索如何控制调试进程的执行,如step逐指令执行、continue运行到断点位置,在后面符号级调试器开发章节,我们还会实现next逐语句执行。 本节我们先实现 `step`命令来支持逐指令执行。 ### 代码实现 逐指令执行,通过执行 `ptrace(PTRACE_SINGLESTEP,...)` 操作即可由内核代为完成。但是在上述操作执行之前,step命令还有些特殊因素要考虑方能正常执行。 此时的PC值有可能是越过了一个断点之后的地址,比如: 1. 一条经过指令patch后的多字节指令,首字节处修改为了0xCC,当前寄存器PC值实际上是该多字节指令的第二个字节的地址,而非首字节的地址。如果对PC值不做修改,处理器执行的时候从第二字节开始解码会解码失败,无法执行指令; 2. 一条单字节指令,如果我们直接decode下一个地址处的指令,还会漏掉断点处原来的一字节指令; 为了保证step正常执行,在 `ptrace(PTRACE_SINGLESTEP,...) ` 之前,需要首先通过 `ptrace(PTRACE_PEEKTEXT,...)` 去读取 `PC-1` 地址处的数据,如果是0xCC,则表明此处为一个断点,需要将添加断点前的原始数据还原、PC=PC-1,然后再继续执行。 **file:cmd/debug/step.go** ```go package debug import ( "fmt" "syscall" "github.com/spf13/cobra" ) var stepCmd = &cobra.Command{ Use: "step", Short: "执行一条指令", Annotations: map[string]string{ cmdGroupKey: cmdGroupCtrlFlow, }, RunE: func(cmd *cobra.Command, args []string) error { dbp := target.DBPProcess defer func() { if err != nil { return } // display current pc regs, err := dbp.ReadRegister(dbp.Process.Pid) if err != nil { fmt.Printf("get regs error: %v", err) return } fmt.Printf("single step ok, current PC: %#x\n", regs.PC()) }() // 读取PC值 regs, err := dbp.ReadRegister(dbp.Process.Pid) if err != nil { return fmt.Errorf("get regs error: %v", err) } // isn't a breakpoint _, ok := dbp.Breakpoint(uintptr(regs.PC() - 1)) if !ok { if _, err = dbp.SingleStep(dbp.Process.Pid); err != nil { return fmt.Errorf("single step err: %v", err) } return nil } // is a breakpoint brk, err := dbp.RestoreInstruction(uintptr(regs.PC() - 1)) if err == target.ErrBreakpointNotExisted { // this 0xcc isn't patched by debugger, and this 0xcc is already executed, // just single step _, err = dbp.SingleStep(dbp.Process.Pid) return err } if err != nil { // debugger inner error return fmt.Errorf("clear breakpoint err: %v", err) } defer dbp.AddBreakpoint(brk.Addr) // rewind pc by 1 regs.SetPC(regs.PC() - 1) if err = dbp.WriteRegister(dbp.Process.Pid, regs); err != nil { return err } // single step if _, err = dbp.SingleStep(dbp.Process.Pid); err != nil { return fmt.Errorf("single step error: %v", err) } return nil }, } func init() { debugRootCmd.AddCommand(stepCmd) } ``` 以上就是step命令的实现代码,但这并不是一个十分友好的实现: - 它确实实现了逐指令执行,完成了本节目标; - 每逐指令执行之后,它还能打印当前寄存器PC值,方便我们确定下条待执行指令地址; 美中不足的是,没有将当前待执行指令的前后指令打印出来,并通过箭头指示下条待执行指令,一种更好的交互可能是这样: ``` godbg> step => 地址1 汇编指令1 地址2 汇编指令2 地址3 汇编指令3 ... ``` 这里会影响到调试体验,我们将在后续过程中予以完善。 > ps:上述代码是 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中的实现,我们重点介绍了step的实现。另外在 [hitzhangjie/golang-debuger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons) /11_step 下,我们也提供了一个step执行的示例,只有一个源文件,与其他demo互不影响,您也可以按照你的想法修改测试下,不用担心改坏整个 godbg的问题。 > > FIXME(demo) 这里的代码实际上也是godbg中的早期实现版本了,后续为了增强维护性,早就进行了大范围的重构。 ### 代码测试 启动一个程序,获取其进程pid,然后执行 `godbg attach `对进程进行调试,等调试会话就绪之后,我们输入 `disass` 反汇编看下当前指令地址之后的汇编指令有哪些。 ```bash godbg> disass 0x40ab47 movb $0x0,0x115(%rdx) 0x40ab4e mov 0x18(%rsp),%rcx 0x40ab53 mov 0x38(%rsp),%rdx 0x40ab58 mov (%rdx),%ebx 0x40ab5a test %ebx,%ebx 0x40ab5c jne 0x4c 0x40ab5e mov 0x30(%rax),%rbx 0x40ab62 movb $0x1,0x115(%rbx) 0x40ab69 mov %rdx,(%rsp) 0x40ab6d movl $0x0,0x8(%rsp) ``` 然后尝试执行 `step` 命令,观察输出情况。 ```bash godbg> step step single step ok, current PC: 0x40ab4e godbg> step step single step ok, current PC: 0x40ab53 godbg> step step single step ok, current PC: 0x40ab58 godbg> ``` 我们执行了step指令3次,step每次执行一条指令之后,会输出执行指令后的PC值,依次是0x40ab4e、0x40ab53、0x40ab58,依次是下条指令的首地址。 不禁要问,执行系统调用 `ptrace(PTRACE_SINGLESTEP,...)` 时,内核是如何实现逐指令执行的?只执行一条机器指令后立即停下来,有点奇妙! ### 更多相关内容:SINGLESTEP 内核是如何处理PTRACE_SINGLESTEP请求的呢?SINGLESTEP确实比较特殊,在man(2)手册里面并没有找到太多有价值的信息: ```bash $ man 2 ptrace ------------------------------------------------------------------ NAME ptrace - process trace SYNOPSIS #include long ptrace(enum __ptrace_request request, pid_t pid, void *addr, void *data); Description ... PTRACE_SINGLESTEP stops [Details of these kinds of stops are yet to be documented.] ``` man(2)手册里面没有太多有价值的相关信息,查看内核源码以及Intel开发手册之后,可以了解到这方面的细节。 1. SINGLESTEP调试在Intel平台上是借助了处理器自身硬件特性来实现的,参考《Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 1: Basic Architecture》,Intel架构处理器是有一个标识寄存器EFLAGS,当通过内核将标志寄存器的TF标志置为1时,处理器会自动进入单步执行模式,清0退出单步执行模式。 > **System Flags and IOPL Field** > > The system flags and IOPL field in the **EFLAGS** register control operating-system or executive operations. **They should not be modified by application programs.** The functions of the system flags are as follows: > > **TF (bit 8) Trap flag** — Set to enable single-step mode for debugging; clear to disable single-step mode. > 2. 我们执行系统调用 `syscall.PtraceSingleStep(...)` 时,实际上是 `ptrace(PTRACE_SINGLESTEP, pid...)` ,此时内核会将被跟踪的tracee的task_struct中的寄存器部分的flags设置为flags |= TRAP,然后调度tracee执行。 3. 调度器执行tracee时会先将其进程控制块task_struct中的硬件上下文信息还原到处理器寄存器中,然后再执行对应tracee的指令。此时处理器发现EFLAGS.TF=1,进入单步执行模式。处理器执行单条指令,执行完成后处理器会自动生成一个陷阱中断,然后重置该标志位。这里的陷阱中断是type-1中断 (中断1是调试异常#DB,中断3是断点异常#BP)。 > **Single-step interrupt** > When a system is instructed to single-step, it will execute one instruction and then stop. > ... > The Intel 8086 trap flag and type-1 interrupt response make it quite easy to implement a single-step feature in an 8086-based system. If the trap flag is set, the 8086 will automatically do a type-1 interrupt after each instruction executes. When the 8086 does a type-1 interrupt, ... > The trap flag is reset when the 8086 does a type-1 interrupt, so the single-step mode will be disabled during the interrupt-service procedure. > 4. 内核中断服务程序负责处理这个中断(中断1,#DB异常),会像处理中断3#BP异常时那样,给tracee发送SIGTRAP,信号处理时进而会暂停tracee调度,然后通过SIGCHLD、__wake_up_parent唤醒tracer,以这种方式通知调试器tracer,“嘿,tracee已经单步执行了一条指令,并且已经停下来等待接收后续调试命令了”。 ps:这就是Intel平台下单步执行的一些细节信息,读者如果对其他硬件平台感兴趣,也可以自行了解下它们是如何设计实现来解决单步调试问题的。 ### 本节小结 本节主要探讨了调试器中step逐指令执行功能的实现原理与具体实现,核心内容包括:通过ptrace(PTRACE_SINGLESTEP)系统调用实现单步执行;处理断点恢复的特殊情况,确保PC值正确性;理解Intel平台下基于EFLAGS.TF标志位的硬件单步执行机制。 本节内容为读者深入理解调试器执行控制机制以及后续实现continue、next等高级调试命令奠定了重要基础。 ================================================ FILE: book/6-develop-inst-debugger/12-continue.md ================================================ ## 执行控制:运行到下个断点 ### 实现目标:`godbg> continue` 运行到下个断点 假定当前tracee处于被调试跟踪、暂停执行状态,如果要运行到下个断点处,应该如何做呢?detach之后,被跟踪的tracee会自动恢复执行,但我们肯定要继续跟踪。 操作系统提供了 `ptrace(PTRACE_CONT,...)` 操作,允许我们恢复tracee执行,此时的tracee仍然被tracer跟踪。当tracee运行到下个断点处时,执行0xCC触发3号中断#BP,内核生成SIGTRAP给tracee,进入信号处理逻辑,暂停tracee并唤醒tracer。 在执行恢复操作前,需要检查当前 `PC-1` 地址处是否是我们添加的断点,如果是则需要将其替换为原始指令数据,并回退PC (PC=PC-1),确保tracee能够正确执行后续指令。 ### 代码实现 continue命令的执行流程如下: 1. 检查当前PC-1处数据是否为0xCC,如果是则说明该处是被patch的断点指令 2. 将断点位置的数据还原为原始指令,并将PC回退1 3. 执行 `ptrace(PTRACE_CONT, ...)` 操作请求操作系统恢复tracee执行 4. 通过 `syscall.Wait4(...)` 等待tracee停下来 5. 当tracee运行到断点处时,会重新触发int3中断,tracer被唤醒后获取寄存器信息 注意:当前PC值是执行了0xCC指令之后的地址值,因此 PC=断点地址+1。 **file: cmd/debug/continue.go** ```go package debug import ( "fmt" "syscall" "github.com/spf13/cobra" ) var continueCmd = &cobra.Command{ Use: "continue", Short: "运行到下个断点", Annotations: map[string]string{ cmdGroupKey: cmdGroupCtrlFlow, }, Aliases: []string{"c"}, RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("continue") // 读取PC值 regs := syscall.PtraceRegs{} err := syscall.PtraceGetRegs(TraceePID, ®s) if err != nil { return fmt.Errorf("get regs error: %v", err) } buf := make([]byte, 1) n, err := syscall.PtracePeekText(TraceePID, uintptr(regs.PC()-1), buf) if err != nil || n != 1 { return fmt.Errorf("peek text error: %v, bytes: %d", err, n) } // read a breakpoint if buf[0] == 0xCC { regs.SetPC(regs.PC() - 1) // TODO refactor breakpoint.Disable()/Enable() methods orig := breakpoints[uintptr(regs.PC())].Orig n, err := syscall.PtracePokeText(TraceePID, uintptr(regs.PC()), []byte{orig}) if err != nil || n != 1 { return fmt.Errorf("poke text error: %v, bytes: %d", err, n) } } err = syscall.PtraceCont(TraceePID, 0) if err != nil { return fmt.Errorf("single step error: %v", err) } // 发起了对tracee执行控制的ptrace请求后,要调用syscall.Wait等待并获取tracee状态变化 var wstatus syscall.WaitStatus var rusage syscall.Rusage _, err = syscall.Wait4(TraceePID, &wstatus, syscall.WSTOPPED, &rusage) if err != nil { return fmt.Errorf("wait error: %v", err) } // display current pc regs = syscall.PtraceRegs{} err = syscall.PtraceGetRegs(TraceePID, ®s) if err != nil { return fmt.Errorf("get regs error: %v", err) } fmt.Printf("continue ok, current PC: %#x\n", regs.PC()) return nil }, } func init() { debugRootCmd.AddCommand(continueCmd) } ``` 上述代码基于cmd/debug/step.go修改实现,详见源文件cmd/debug/continue.go。 > 注:上述代码来自 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 项目。另外在 [hitzhangjie/golang-debuger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons) /12_continue 下提供了独立的continue执行示例,可单独测试修改。 ### 代码测试 测试步骤如下: 1. 启动一个进程,获取其pid 2. 通过 `godbg attach ` 对目标进程进行调试 3. 调试会话就绪后,输入 `dis`(disass命令的别名)进行反汇编 4. 选择合适的指令地址添加断点 5. 执行continue命令运行到断点 注意:添加断点时要考虑代码执行时的分支控制逻辑,确保断点位于实际的执行路径上,否则可能无法验证continue运行到断点的功能。 为了验证运行到下个断点,我多次运行dis、step,直到发现有一段指令可以连续执行,中间没有什么跳转操作,如下图所示: ```bash godbg> dis ... godbg> dis ... godbg> dis 0x42e2e0 cmp $-0x4,%eax ; 从这条语句开始执行 0x42e2e3 jne 0x24c 0x42e2e9 mov 0x20(%rsp),%eax 0x42e2ed test %eax,%eax ; 首字节被覆盖成0xCC,PC=0x42e2ed+1 0x42e2ef jle 0xffffffffffffffbe 0x42e2f1 movq $0x0,0x660(%rsp) 0x42e2fd mov 0x648(%rsp),%rbp 0x42e305 add $0x650,%rsp 0x42e30c retq 0x42e30d movq $0x0,0x30(%rsp) godbg> ``` 然后我们尝试break添加断点、continue运行到断点: ```bash godbg> b 0x42e2ed break 0x42e2ed 添加断点成功 godbg> c continue continue ok, current PC: 0x42e2ee ``` 我们在第4条指令 `0x42e2ed test %eax,%eax` 处添加断点,然后执行 `c`(continue的别名)运行到断点处。运行结果显示当前PC值为0x42e2ee=0x42e2ed+1,这是因为被调试进程在执行了0x42e2ed处的0xCC断点指令后才停下来,完全符合预期。 ### 思考:这样的实现是完备的吗? 我们只考虑了1个tracee线程的情况,而go程序是多线程程序,如果我们要对一个go进程进行调试,那么必须对多线程调试进行支持。否则我们只continue一个线程,而其他线程都处于stopped状态,那么这个线程可能无法正常与其他线程同步状态,可能根本就无法进行调试了。我们前面提到过类似的设计,这些tracee线程要么都停止、要么都运行,这样方便我们调试。 所以当我们执行continue命令时,需要特别注意对多线程调试的支持,修改后的支持go多线程的continue实现如下: ```go package debug import ( "fmt" "os" "github.com/hitzhangjie/godbg/pkg/target" "github.com/spf13/cobra" ) var continueCmd = &cobra.Command{ Use: "continue", Short: "运行到下个断点", Annotations: map[string]string{ cmdGroupAnnotation: cmdGroupCtrlFlow, }, Aliases: []string{"c"}, RunE: func(cmd *cobra.Command, args []string) (err error) { dbp := target.DBPProcess // 获取当前停在断点处的线程 bpStoppedThreads, err := dbp.ThreadStoppedAtBreakpoint() if err != nil { return fmt.Errorf("check thread breakpoints error: %v", err) } // 如果没有线程停在断点处,直接继续执行即可 if len(bpStoppedThreads) == 0 { return dbp.Continue() } // 有线程停在断点处,恢复断点,rewind线程pc,singlestep后恢复断点 bpCleared := make(map[uintptr]struct{}) for tid, bpAddr := range bpStoppedThreads { fmt.Printf("Thread %d stopped at breakpoint %#x\n", tid, bpAddr) // - rewind线程pc regs, err := dbp.ReadRegister(tid) if err != nil { return fmt.Errorf("read register for thread %d: %v", tid, err) } regs.SetPC(regs.PC() - 1) if err = dbp.WriteRegister(tid, regs); err != nil { return fmt.Errorf("write register for thread %d: %v", tid, err) } // - 还原指令数据 if _, cleared := bpCleared[bpAddr]; !cleared { _, err := dbp.RestoreInstruction(bpAddr) if err != nil && err != target.ErrBreakpointNotExisted { return fmt.Errorf("clear breakpoint at %#x error: %v", bpAddr, err) } bpCleared[bpAddr] = struct{}{} } // - singlestep后,要恢复断点 _, err = dbp.SingleStep(tid) if err != nil { return fmt.Errorf("single step for thread %d: %v", tid, err) } if _, err := dbp.AddBreakpoint(bpAddr); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to restore breakpoint at %#x: %v\n", bpAddr, err) } else { fmt.Printf("restored breakpoint at %#x\n", bpAddr) } } // 然后再恢复所有tracee执行 if err = dbp.Continue(); err != nil { return fmt.Errorf("continue error: %v", err) } fmt.Println("continue ok") return nil }, } func init() { debugRootCmd.AddCommand(continueCmd) } ``` ### 更多相关内容 continue命令对于符号级调试器至关重要。在源代码向汇编指令的转换过程中,一条源代码语句可能对应多条机器指令。当我们需要: - 逐语句执行 - 进入、退出函数(函数有prologue、epilogue) - 进入、退出循环体 实现上述源码级调试功能时,必须借助对源码及指令的理解,在正确的地址处设置断点,然后配合continue命令来实现。 我们将在符号级调试器一章中更详细地研究这些内容。 ### 本节小结 本节主要探讨了调试器中continue命令的实现原理和具体实现,核心内容包括:通过 `ptrace(PTRACE_CONT,...)`恢复tracee执行,并等待其运行到下一个断点或者执行结束;在恢复执行前需要检查并还原断点处的原始指令数据,同时调整PC寄存器值,以确保指令解码正常;使用 `syscall.Wait4`等待tracee在断点处停止并获取其状态变化。本节重点强调了断点恢复机制的重要性——必须将0xCC断点指令还原为原始指令并回退PC,确保tracee能够正确执行后续指令。 这些底层机制为符号级调试器提供了基础支撑,使得调试器能够实现逐语句执行、函数进入退出等高级调试功能。本节内容为读者理解调试器的执行控制机制和后续学习符号级调试器打下了坚实的技术基础。 ================================================ FILE: book/6-develop-inst-debugger/13-pmem.md ================================================ ## 查看进程状态:查看内存数据 ### 实现目标:`godbg> pmem` 查看内存数据 这一小节,我们来实现pmem命令,方便调试进程时查看进程内存数据。 ### 基础知识 我们知道,内存中的数据按字节编址,1字节包含8bits,是一些0和1序列,要正确展示内存数据,我们需要关注下面这些基本事项: - 字节、0和1bits序列不是我们要看的最终形态,数据是有类型的,我们要根据数据类型来解释内存中的数据; - 不同机器中数据存储是有字节序的,小端(低有效位数据在内存低地址)or 大端(低有效位数据在内存高地址); pmem查看进程内存数据,需要通过 `ptrace(PTRACE_PEEKDATA,...)` 操作,来读取被调试进程的从指定内存地址开始的一段数据,并按照指定字节序编组成一个个整数,然后以二进制、八进制、十进制或者十六进制的形式打印出来。 和常见的查看变量的操作 `print ` 不同的是,这里并没有考虑指定位置的数据是什么数据类型 (如一个 `struct{...}` ,`slice`,or `map`)。pmem类似gdb里面的 `x/fmt` 操作。 ### 代码实现 #### 第1步:实现进程内存数据读取 首先,我们通过 `ptrace(PTRACE_PEEKDATA,...)` 系统调用实现对内存内数据的读取,每次读取的数据量可以由count和size计算得到: - size表示一个待读取并显示的数据项包括多少个字节; - count表示连续读取并显示多少个这样的数据项; 比如一个int数据项可能包含4个字节,要显示8个int数则要指定 `-size=4 -count=8`。 下面的程序读取内存数据,并以16进制数打印读取的字节数据。 **file: cmd/debug/pmem.go** ```go package debug import ( "errors" "fmt" "strconv" "syscall" "github.com/spf13/cobra" ) var pmemCmd = &cobra.Command{ Use: "pmem ", Short: "打印内存数据", Annotations: map[string]string{ cmdGroupKey: cmdGroupInfo, }, RunE: func(cmd *cobra.Command, args []string) error { count, _ := cmd.Flags().GetUint("count") format, _ := cmd.Flags().GetString("fmt") size, _ := cmd.Flags().GetUint("size") addr, _ := cmd.Flags().GetString("addr") // check params err := checkPmemArgs(count, format, size, addr) if err != nil { return err } // calculate size of memory to read readAt, _ := strconv.ParseUint(addr, 0, 64) bytes := count * size buf := make([]byte, bytes, bytes) n, err := syscall.PtracePeekData(TraceePID, uintptr(readAt), buf) if err != nil || n != int(bytes) { return fmt.Errorf("read %d bytes, error: %v", n, err) } // print result fmt.Printf("read %d bytes ok:", n) for _, b := range buf[:n] { fmt.Printf("%x", b) } fmt.Println() return nil }, } func init() { debugRootCmd.AddCommand(pmemCmd) // 类似gdb的命令x/FMT,其中FMT=重复数字+格式化修饰符+size pmemCmd.Flags().Uint("count", 16, "查看数值数量") pmemCmd.Flags().String("fmt", "hex", "数值打印格式: b(binary), o(octal), x(hex), d(decimal), ud(unsigned decimal)") pmemCmd.Flags().Uint("size", 4, "数值占用字节") pmemCmd.Flags().String("addr", "", "读取的内存地址") } func checkPmemArgs(count uint, format string, size uint, addr string) error { if count == 0 { return errors.New("invalid count") } if size == 0 { return errors.New("invalid size") } formats := map[string]struct{}{ "b": {}, "o": {}, "x": {}, "d": {}, "ud": {}, } if _, ok := formats[format]; !ok { return errors.New("invalid format") } // TODO make it compatible _, err := strconv.ParseUint(addr, 0, 64) return err } ``` #### 第2步:判断字节序及数值解析 ```go // 检测是否是小端字节序 func isLittleEndian() bool { buf := [2]byte{} *(*uint16)(unsafe.Pointer(&buf[0])) = uint16(0xABCD) switch buf { case [2]byte{0xCD, 0xAB}: return true case [2]byte{0xAB, 0xCD}: return false default: panic("Could not determine native endianness.") } } // 将byteslice转成uint64数值,注意字节序 func byteArrayToUInt64(buf []byte, isLittleEndian bool) uint64 { var n uint64 if isLittleEndian { for i := len(buf) - 1; i >= 0; i-- { n = n<<8 + uint64(buf[i]) } } else { for i := 0; i < len(buf); i++ { n = n<<8 + uint64(buf[i]) } } return n } ``` #### 第3步:实现数据"类型"解析 从内存中读取到的数据,应该按照每个数据项占用的字节数 `-size`以及要展示的进制 `-fmt` 对连续字节数据进行编组、解析。且需要考虑二进制、八进制、十进制、十六进制数展示时占用的终端列数问题,每行列数有限,使用不同的进制数情况下,每行适合展示的数字的数量不同。 ```go package debug ... var pmemCmd = &cobra.Command{ Use: "pmem ", Short: "打印内存数据", Annotations: map[string]string{ cmdGroupKey: cmdGroupInfo, }, RunE: func(cmd *cobra.Command, args []string) error { ... // 该函数以美观的tab+padding对齐方式打印数据 s := prettyPrintMem(uintptr(readAt), buf, isLittleEndian(), format[0], int(size)) fmt.Println(s) return nil }, } ... // prettyPrintMem 使用tabwriter控制对齐. // // 注意结合2、8、10、16进制的显示情况进行适当的格式化处理后,再予以显示,看起来更美观 func prettyPrintMem(address uintptr, memArea []byte, littleEndian bool, format byte, size int) string { var ( cols int // 不同进制数,每行展示的列数(如cols=4, 1 2 3 4) colFormat string // 不同进制数,每列数字格式化方式(如%08b, 00000001) colBytes = size // 每列数字占用字节数(如2, 需2个字节,考虑字节序) addrLen int addrFmt string ) switch format { case 'b': cols = 4 // Avoid emitting rows that are too long when using binary format colFormat = fmt.Sprintf("%%0%db", colBytes*8) case 'o': cols = 8 colFormat = fmt.Sprintf("0%%0%do", colBytes*3) // Always keep one leading zero for octal. case 'd': cols = 8 colFormat = fmt.Sprintf("%%0%dd", colBytes*3) case 'x': cols = 8 colFormat = fmt.Sprintf("0x%%0%dx", colBytes*2) // Always keep one leading '0x' for hex. default: return fmt.Sprintf("not supprted format %q\n", string(format)) } colFormat += "\t" // the number of rows to print l := len(memArea) rows := l / (cols * colBytes) if l%(cols*colBytes) != 0 { rows++ } // We should print memory address in the beginnning of every line. // And we should use fixed length bytes to print the address for // better readability. if l != 0 { addrLen = len(fmt.Sprintf("%x", uint64(address)+uint64(l))) } addrFmt = "0x%0" + strconv.Itoa(addrLen) + "x:\t" // use tabwriter to print lines with columns aligned vertically. var b strings.Builder w := tabwriter.NewWriter(&b, 0, 0, 3, ' ', 0) for i := 0; i < rows; i++ { fmt.Fprintf(w, addrFmt, address) for j := 0; j < cols; j++ { offset := i*(cols*colBytes) + j*colBytes if offset+colBytes <= len(memArea) { n := byteArrayToUInt64(memArea[offset:offset+colBytes], littleEndian) fmt.Fprintf(w, colFormat, n) } } fmt.Fprintln(w, "") address += uintptr(cols) } w.Flush() return b.String() } ``` 上面的代码读取内存数据逻辑不变,主要是添加了两部分逻辑: - 根据机器大小端字节序,对内存中读取到的数据进行正确解析,并转换成对应的数值; - 根据数值要显示的进制格式,结合2、8、16、10进制的宽度,通过fmt.Sprintf进行适当格式化,并结合tabwrite通过tab+padding对齐后输出; 至此pmem命令基本完成开发,我们来测试下pmem的执行情况。 ### 代码测试 #### 测试:内存数据读取 首先运行测试程序,获取其pid,然后运行 `godbg attach `跟踪目标进程,等调试会话就绪后,我们输入 `disass`查看下反汇编数据,显示有很多的 `int3`指令,其对应的字节数据是 `0xCC`,我们可以读取一字节该指令地址处的数据来快速验证pmem是否工作正常。 ```bash $ godbg attach 7764 process 7764 attached succ process 7764 stopped: true godbg> disass 0x4651e0 mov %eax,0x20(%rsp) 0x4651e4 retq 0x4651e5 int3 0x4651e6 int3 0x4651e7 int3 0x4651e8 int3 0x4651e9 int3 0x4651ea int3 0x4651eb int3 0x4651ec int3 godbg> pmem --addr 0x4651e5 --count 1 --fmt x --size 1 read 1 bytes ok:cc godbg> pmem --addr 0x4651e5 --count 4 --fmt x --size 1 read 4 bytes ok:cccccccc godbg> ``` 可见,程序从指令地址0x4561e5先读取了1字节数据,即1个int3对应的16进制数0xCC,然后从相同地址处读取了4字节数据,即连续4个int3对应的16进制数0xCCCCCCC。 运行结果符合预期,说明pmem基本的内存数据读取功能正常。 #### 测试:数据"类型"解析 查看16进制数,每个16进制数分别为1字节、2字节,注意字节序为小端: ```bash godbg> pmem --addr 0x464fc3 --count 16 --fmt x --size 1 read 16 bytes ok: 0x464fc3: 0x89 0x44 0x24 0x30 0xc3 0xcc 0xcc 0xcc 0x464fcb: 0xcc 0xcc 0xcc 0xcc 0xcc 0xcc 0xcc 0xcc godbg> pmem --addr 0x464fc3 --count 16 --fmt x --size 2 read 32 bytes ok: 0x464fc3: 0x4489 0x3024 0xccc3 0xcccc 0xcccc 0xcccc 0xcccc 0xcccc 0x464fcb: 0xcccc 0xcccc 0xcccc 0xcccc 0xcccc 0xcccc 0x8bcc 0x247c ``` 查看8进制数,每个8进制数分别为1字节、2字节,注意字节序为小端: ```bash godbg> pmem --addr 0x464fc3 --count 16 --fmt o --size 1 read 16 bytes ok: 0x464fc3: 0211 0104 0044 0060 0303 0314 0314 0314 0x464fcb: 0314 0314 0314 0314 0314 0314 0314 0314 godbg> pmem --addr 0x464fc3 --count 16 --fmt o --size 2 read 32 bytes ok: 0x464fc3: 0042211 0030044 0146303 0146314 0146314 0146314 0146314 0146314 0x464fcb: 0146314 0146314 0146314 0146314 0146314 0146314 0105714 0022174 ``` 查看2进制数,每个2进制数分别为1字节、2字节,注意字节序为小端: ```bash godbg> pmem --addr 0x464fc3 --count 16 --fmt b --size 1 read 16 bytes ok: 0x464fc3: 10001001 01000100 00100100 00110000 0x464fc7: 11000011 11001100 11001100 11001100 0x464fcb: 11001100 11001100 11001100 11001100 0x464fcf: 11001100 11001100 11001100 11001100 godbg> pmem --addr 0x464fc3 --count 16 --fmt b --size 2 read 32 bytes ok: 0x464fc3: 0100010010001001 0011000000100100 1100110011000011 1100110011001100 0x464fc7: 1100110011001100 1100110011001100 1100110011001100 1100110011001100 0x464fcb: 1100110011001100 1100110011001100 1100110011001100 1100110011001100 0x464fcf: 1100110011001100 1100110011001100 1000101111001100 0010010001111100 ``` 最后,查看下10进制数,每个10进制数分别为1字节、2字节,注意字节序为小端: ```bash godbg> pmem --addr 0x464fc3 --count 16 --fmt d --size 1 read 16 bytes ok: 0x464fc3: 137 068 036 048 195 204 204 204 0x464fcb: 204 204 204 204 204 204 204 204 godbg> pmem --addr 0x464fc3 --count 16 --fmt d --size 2 read 32 bytes ok: 0x464fc3: 017545 012324 052419 052428 052428 052428 052428 052428 0x464fcb: 052428 052428 052428 052428 052428 052428 035788 009340 ``` pmem命令可以正常解析不同fmt、不同size、大小端字节序的内存数据了。 运行结果符合预期,说明pmem数据读取、解析、展示功能均正常。 > ps: 这里prettyPrintMem逻辑实际上取自当初贡献给 `go-delve/delve的examinemem(x)`命令。如您对字节序引起的数据转换感兴趣,可以对数据进行校验验证下正确性,通过16进制数据校验可能会更方便些。 ### 本节小结 本文介绍了如何从指定内存地址读取数据,如何高效判断机器字节序,不同字节序下如何进行数值解析,并且以不同计数制进行格式化展示。这里我们还是用了go标准库中提供的一个好用的包tabwriter,它支持输出的数据按列对齐,使得输出更加清晰易读。这个操作在符号级调试器中也是基础操作,比如我们想查看slice、string底层字节数组中的真实数据构成。 后续实现符号级调试器时,我们会介绍 `print ` 后者 `print ` 中更加深入的一些只是,包括AST解析,然后EvalExpr,然后从内存读取每个操作变量的内存数据,运算得到结果变量。并将这个结果变量的值按照变量类型予以显示出来,比如 `print student.Name`。这里我们先聚焦原始的pmem操作。 接下来,我们继续介绍如何读取寄存器相关数据。 ================================================ FILE: book/6-develop-inst-debugger/14-pregs.md ================================================ ## 查看进程状态: 查看寄存器数据 ### 实现目标:`godbg> pregs` 查看寄存器数据 这一小节,我们来实现pregs命令,方便调试进程时查看进程寄存器数据。对于指令级调试而言,我们通过反汇编看到待执行的汇编指令,为了搞清楚指令的操作数,我们需要借助pmem来查看内存数据,也需要pregs来查看寄存器数据。这就好比符号级调试器看到源码后需要知道对应的变量值一样。 在前面章节中我们已经不止一次使用 `ptrace(PTRACE_GETREGS,...)` 获取寄存器数据了,这里我们需要单独支持一个pregs的调试命令,每次执行该命令打印出当前所有寄存器的信息,我们就不像gdb那样支持单独打印某个寄存器的信息了。 > ps:指令级调试门槛是有点高的,至少要懂汇编语言,或者在某些工具帮助下能看得懂汇编语言,也有些工具支持从指令数据还原出对应的高级语言源码,如生成对应的C程序,但是因为变量名、函数名问题即使生成了可读性也比较差,只能看程序组织、调用方式。在这个过程中,不同处理器对应的寄存器也不一样,比如i386、amd64、arm64等,这要求开发人员必须参考并了解相关的细节才能顺利调试。 ### 代码实现 查看进程寄存器数据,需要通过 `ptrace(PTRACE_GETREGS,...)` 操作来读取被调试进程的寄存器数据。 **file: cmd/debug/pregs.go** ```go package debug import ( "fmt" "os" "reflect" "syscall" "text/tabwriter" "github.com/spf13/cobra" ) var pregsCmd = &cobra.Command{ Use: "pregs", Short: "打印寄存器数据", Annotations: map[string]string{ cmdGroupKey: cmdGroupInfo, }, RunE: func(cmd *cobra.Command, args []string) error { regsOut := syscall.PtraceRegs{} err := syscall.PtraceGetRegs(TraceePID, ®sOut) if err != nil { return fmt.Errorf("get regs error: %v", err) } prettyPrintRegs(regsOut) return nil }, } func init() { debugRootCmd.AddCommand(pregsCmd) } func prettyPrintRegs(regs syscall.PtraceRegs) { w := tabwriter.NewWriter(os.Stdout, 0, 8, 4, ' ', 0) rt := reflect.TypeOf(regs) rv := reflect.ValueOf(regs) for i := 0; i < rv.NumField(); i++ { fmt.Fprintf(w, "Register\t%s\t%#x\t\n", rt.Field(i).Name, rv.Field(i).Uint()) } w.Flush() } ``` 程序首先通过ptrace获取寄存器数据,然后通过prettyPrintRegs打印寄存器信息。其中,prettyPrintRegs函数使用了 `tabwriter`对寄存器数据按样式“**Register 寄存器名 寄存器值**”格式化输出,便于查看。 > tabwrite对于需要输出多行、多列数据且需要对每列数据进行对齐的场景非常适用。 ### 代码测试 首先启动一个测试程序充当被调试进程,获取其pid,然后通过 `godbg attach `对目标进程进行调试。等调试会话准备就绪后,输入命令pregs查看寄存器信息。 ```bash $ godbg attach 116 process 116 attached succ process 116 stopped: true godbg> pregs Register R15 0x400 Register R14 0x3 Register R13 0xa Register R12 0x4be86f Register Rbp 0x7ffc5095bd50 Register Rbx 0x555900 Register R11 0x286 Register R10 0x0 Register R9 0x0 Register R8 0x0 Register Rax 0xfffffffffffffe00 Register Rcx 0x464fc3 Register Rdx 0x0 Register Rsi 0x80 Register Rdi 0x555a48 Register Orig_rax 0xca Register Rip 0x464fc3 Register Cs 0x33 Register Eflags 0x286 Register Rsp 0x7ffc5095bd08 Register Ss 0x2b Register Fs_base 0x555990 Register Gs_base 0x0 Register Ds 0x0 Register Es 0x0 Register Fs 0x0 Register Gs 0x0 godbg> ``` 我们看到pregs命令显示了三列数据: - 第1列统一为Register,没有什么特殊含义,只是为了可读性和美观性; - 第2列为寄存器名称,为了美观采用了左对齐; - 第3列为寄存器当前值,采用16进制数打印,为了美观采用了左对齐; 调试过程中有时需要查看、修改寄存器状态,比如查看、修改返回值(返回值通常记录在rax寄存器中,但是go语言支持多值返回,对返回值的处理有点特殊之处)。 ### 本节小结 截止到目前我们实现了pmem、pregs这两个查看内存、查看寄存器数据的命令,只查看还不够,我们还应该实现修改内存数据、修改寄存器数据的操作,我们会在后面小节介绍。 ================================================ FILE: book/6-develop-inst-debugger/15-set-mem.md ================================================ ## 修改进程状态: 修改内存数据 ### 实现目标: `godbg> setmem ` 修改内存数据 添加、移除断点过程中其实也是对内存数据做修改,只不过断点操作是修改的指令数据,而我们这里强调的是对数据做修改。 指令级调试器对内存数据做修改,其实没有符号级调试器直接通过变量名来修改容易,对调试人员的要求比较高。因为如果不知道什么数据在内存什么位置,是什么类型,占多少字节,所以不好修改。符号级调试器就简单多了,直接通过变量名来修改就可以。本节演示下对内存数据区数据做修改的操作,介绍下用到的系统调用 `ptrace(PTRACE_POKEDATA,...)` ,符号级调试器里通过变量名来修改值时也要用到这个系统调用。 OK,我们先还是先介绍如何修改任意指定地址处的内存数据,然后会在 godbg 中实现此set命令:`set ` 。 ### 代码实现 我们实现一个程序,该程序会跟踪被调试进程,然后会提示输入变量的地址和新变量值,然后我们将变量地址处的内存数据修改为新变量值。 那如何确定这个变量的地址呢?我们会实现一个go程序,编译构建启动后,我们会先用dlv这个符号级调试器来跟踪它,然后确定它的变量地址后,再detach,然后再交给我们这里的程序来attach被调试进程,就可以输入准确的变量地址、新变量值进行测试了。 OK,我们看下这里的程序的实现,这个程序编译为 ./15_set_mem: ```go package main import ( "fmt" "os" "os/exec" "runtime" "strconv" "syscall" "time" ) var usage = `Usage: go run main.go args: - pid: specify the pid of process to attach ` func main() { runtime.LockOSThread() if len(os.Args) != 2 { fmt.Println(usage) os.Exit(1) } // pid pid, err := strconv.Atoi(os.Args[1]) if err != nil { panic(err) } if !checkPid(int(pid)) { fmt.Fprintf(os.Stderr, "process %d not existed\n\n", pid) os.Exit(1) } // step1: supposing running dlv attach here fmt.Fprintf(os.Stdout, "===step1===: supposing running `dlv attach pid` here\n") // attach err = syscall.PtraceAttach(int(pid)) if err != nil { fmt.Fprintf(os.Stderr, "process %d attach error: %v\n\n", pid, err) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d attach succ\n\n", pid) // check target process stopped or not var status syscall.WaitStatus var rusage syscall.Rusage _, err = syscall.Wait4(int(pid), &status, syscall.WSTOPPED, &rusage) if err != nil { fmt.Fprintf(os.Stderr, "process %d wait error: %v\n\n", pid, err) os.Exit(1) } if !status.Stopped() { fmt.Fprintf(os.Stderr, "process %d not stopped\n\n", pid) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d stopped\n\n", pid) regs := syscall.PtraceRegs{} if err := syscall.PtraceGetRegs(int(pid), ®s); err != nil { fmt.Fprintf(os.Stderr, "get regs fail: %v\n", err) os.Exit(1) } fmt.Fprintf(os.Stdout, "tracee stopped at %0x\n", regs.PC()) // step2: supposing running list and disass go get the address of interested code time.Sleep(time.Second * 2) var input string fmt.Fprintf(os.Stdout, "enter a address you want to modify data\n") _, err = fmt.Fscanf(os.Stdin, "%s", &input) if err != nil { panic("read address fail") } addr, err := strconv.ParseUint(input, 0, 64) if err != nil { panic(err) } fmt.Fprintf(os.Stdout, "you entered %0x\n", addr) fmt.Fprintf(os.Stdout, "enter a value you want to change to\n") _, err = fmt.Fscanf(os.Stdin, "%s", &input) if err != nil { panic("read value fail") } val, err := strconv.ParseUint(input, 0, 64) if err != nil { panic("read value fail") } fmt.Fprintf(os.Stdout, "you entered %x\n", val) fmt.Fprintf(os.Stdout, "we'll set *(%x) = %x\n", addr, val) // step2: supposing runnig step here time.Sleep(time.Second * 2) fmt.Fprintf(os.Stdout, "===step2===: supposing running `dlv> set *addr = 0xaf` here\n") var data [1]byte n, err := syscall.PtracePeekData(int(pid), uintptr(addr), data[:]) if err != nil || n != 1 { fmt.Fprintf(os.Stderr, "read data fail: %v\n", err) os.Exit(1) } n, err = syscall.PtracePokeData(int(pid), uintptr(addr), []byte{byte(val)}) if err != nil || n != 1 { fmt.Fprintf(os.Stderr, "write data fail: %v\n", err) os.Exit(1) } fmt.Fprintf(os.Stdout, "change data from %x to %d succ\n", data[0], val) } // checkPid check whether pid is valid process's id // // On Unix systems, os.FindProcess always succeeds and returns a Process for // the given pid, regardless of whether the process exists. func checkPid(pid int) bool { out, err := exec.Command("kill", "-s", "0", strconv.Itoa(pid)).CombinedOutput() if err != nil { panic(err) } // output error message, means pid is invalid if string(out) != "" { return false } return true } ``` ### 代码测试 下面来说明下这里的测试方法,为了方便测试我们需要先准备一个测试程序,方便我们好获取某个变量的地址,然后我们修改这个变量的值,通过程序执行效果来印证修改是否生效。 1、首先我们准备了一个测试程序 testdata/loop.go 这个程序通过一个for循环每隔1s打印当前进程的pid,循环控制变量loop默认为true。 ```go package main import ( "fmt" "os" "time" ) func main() { loop := true for loop { fmt.Println("pid:", os.Getpid()) time.Sleep(time.Second) } } ``` 2、我们先构建并运行这个程序,`go build -gcflags 'all=-N -l'`,或者执行写好的makefile: ```bash $ cd ../testdata && make $./loop pid:49701 pid:49701 pid:49701 pid:49701 pid:49701 ... ``` 3、然后我们借助dlv来观察变量loop的内存位置 ```bash $dlv attach 49701 (dlv) b loop.go:11 Breakpoint 1 set at 0x4af0f9 for main.main() ./debugger101/golang-debugger-lessons/testdata/loop.go:11 (dlv) c > [Breakpoint 1] main.main() ./debugger101/golang-debugger-lessons/testdata/loop.go:11 (hitsgoroutine(1):1total:1) (PC:0x4af0f9) 6: "time" 7: ) 8: 9:funcmain() { 10: loop := true => 11: forloop { 12: fmt.Println("pid:",os.Getpid()) 13: time.Sleep(time.Second) 14: } 15:} (dlv) p &loop (*bool)(0xc0000caf17) (dlv) x 0xc0000caf17 0xc0000caf17: 0x01 ... ``` 4、然后我们让dlv进程退出恢复loop的执行 ```bash (dlv) quit Would you like to kill the process? [Y/n] n ``` 5、然后我们执行自己的程序 ```bash ./14_set_mem 49701 ===step1===: supposing running `dlv attach pid` here process 49701 attach succ process 49701 stopped tracee stopped at 476203 enter a address you want to modify data <= input address of variable `loop` 0xc0000caf17 you entered c0000caf17 enter a value you want to change to <= input false of variable `loop` 0x00 you entered 0 we'll set *(c0000caf17) = 0 <= do loop=false ===step2===: supposing running `dlv> set *addr = 0xaf` here <= do loop=false succ change data from 1 to 0 succ ``` 此时,由于 `loop=false` 所以 `for loop {...}` 循环结束,程序会执行到结束。 ```bash pid:49701 pid:49701 pid:49701 <= tracee exit successfully for `loop=false` zhangjie🦀testdata(master) $ ``` ### 本节小结 本文我们实现了指令级调试器修改任意内存地址处的数据的功能,这个功能非常重要,我们都知道修改内存数据对于调试修改程序执行行为的重要性。了解了这里的实现技术后,我们将在实现符号级调试时继续实现对变量值的修改,对于实用高级语言进行开发的开发者来说,调整变量值是一个非常重要的观察程序执行行为的功能。 下一节我们将继续查看下如何修改寄存器的值,这在某些调试场景下也是很重要的。 ================================================ FILE: book/6-develop-inst-debugger/16-set-regs.md ================================================ ## 修改进程状态:修改寄存器数据 ### 实现目标:`godbg> setreg ` 修改寄存器数据 我们已经展示过如何读取并且修改寄存器数据了,比如continue命令执行时,如果当前PC-1处是软件断点0xCC,我们需要重置断点并且设置寄存器PC=PC-1。 和当时设置PC=PC-1相同,我们这里用到的寄存器修改方法仍然是通过 `ptrace(PTRACE_SET_REGS,...)`。所不同的是本小节要实现一个通用的寄存器修改命令 `setreg ` 。 当高级语言代码被构建完成后就变成了一系列的机器指令,机器指令的操作数可以是立即数、内存地址、寄存器编号。我们在使用符号级调试器的时候,有时候会改变变量值(迭代变量、函数参数、函数返回值等等)来控制程序执行逻辑。其实在指令级调试时,也是有这样的需求去修改内存中的数据、寄存器中的数据,所以我们需要有修改内存命令setmem、修改寄存器命令setreg命令。 ps: 当然从易用性角度来说,可以使用一个set命令来实现setmem、setreg、setvar,但是我们是为了教学目的,所以每个操作最好相对独立,这样逻辑清晰简单、篇幅也更简短。 ### 代码实现 godbg中的实现也非常简单,接收用户输入的寄存器名args[0]、要设置的值args[1],然后通过 `syscall.PtraceGetRegs(...)` 操作拿到所有寄存器的值regs,并通过反射找到代表对应寄存器名的字段(如regs.rax),并修改字段值,最后将修改后的regs再通过 `syscall.PtraceSetRegs(...)` 设置回寄存器。 ```go package debug import ( "errors" "fmt" "reflect" "strconv" "strings" "github.com/hitzhangjie/godbg/pkg/target" "github.com/spf13/cobra" ) var setRegCmd = &cobra.Command{ Use: "setreg ", Short: "设置寄存器值", Annotations: map[string]string{ cmdGroupAnnotation: cmdGroupInfo, }, RunE: func(cmd *cobra.Command, args []string) error { // 检查参数数量 if len(args) != 2 { return errors.New("usage: setreg ") } // 检查是否有调试进程 if target.DBPProcess == nil { return errors.New("please attach to a process first") } regName := strings.ToLower(args[0]) valueStr := args[1] // 解析值参数 value, err := strconv.ParseUint(valueStr, 0, 64) if err != nil { return fmt.Errorf("invalid value format: %s", valueStr) } // 读取当前寄存器状态 regs, err := target.DBPProcess.ReadRegister() if err != nil { return fmt.Errorf("failed to read registers: %v", err) } // 使用反射设置寄存器值 rv := reflect.ValueOf(regs).Elem() rt := reflect.TypeOf(*regs) var fieldFound bool for i := 0; i < rv.NumField(); i++ { fieldName := strings.ToLower(rt.Field(i).Name) if fieldName == regName { // 设置新值 rv.Field(i).SetUint(value) fieldFound = true // 写回寄存器 err = target.DBPProcess.WriteRegister(regs) if err != nil { return fmt.Errorf("failed to write register %s: %v", regName, err) } break } } if !fieldFound { return fmt.Errorf("invalid register name: %s", regName) } return nil }, } func init() { debugRootCmd.AddCommand(setRegCmd) } ``` ### 代码测试1:修改寄存器值并查看 首先我们先执行一个简单的测试: ```bash $ while [ 1 -eq 1 ]; do echo $$; sleep 1; done 1521639 1521639 1521639 1521639 1521639 1521639 1521639 <= godbg attach 1521639 ``` 然后我们执行调试跟踪: ```bash root🦀 ~ $ godbg attach 1521639 process 1521639 attached succ process 1521639 stopped: true godbg> godbg> pregs <= pregs显示当前寄存器信息,其中R12=0x1 Register R15 0x7ffd8a1e55e0 Register R14 0x0 Register R13 0x7ffd8a1e56b0 Register R12 0x1 Register Rbp 0x0 Register Rbx 0xa Register R11 0x246 Register R10 0x0 ... godbg> setreg r12 0x2 <= 执行setreg命令修改R12=0x2 godbg> pregs <= 再次查看当前寄存器信息,R12=0x2,修改成功 Register R15 0x7ffd8a1e55e0 Register R14 0x0 Register R13 0x7ffd8a1e56b0 Register R12 0x2 Register Rbp 0x0 Register Rbx 0xa Register R11 0x246 Register R10 0x0 ... godbg> ``` OK,这个测试演示了调试精灵setreg基本的用法和执行效果。 有的读者可能会想,什么情况下我需要显示修改寄存器,真有这种情景吗?下面咱们就来看一个相对更实际的案例。 ### 代码测试2:篡改返回值跳出循环 #### 无法修改返回变量值来跳出循环 :( 我们先实现一个测试程序,该测试程序每隔1s打印一下进程pid,for-loop的循环条件是一个固定返回true的函数loop(),我们想通过修改寄存器的方式来篡改函数调用 `loop()`的返回值来实现。 file: main.go ```go package main import ( "fmt" "os" "time" ) func main() { for loop() { fmt.Println("pid:", os.Getpid()) time.Sleep(time.Second) } } //go:noinline func loop() bool { return true } ``` 这里的挑战点在于,`for loop() {}` 而不是 `for v := true; v ; v = loop() {}`,在loop函数体内部是 `return true` 而不是 `v := true; return v`。我们既不能通过 `set ` 来修改loop()返回值的值,也不能修改loop函数体内部return的值。 此时我们只能在返回前修改ret指令的操作数的值,或者loop函数调用返回后修改返回值寄存器的值。修改ret指令的操作数寄存器也可以,我们这里演示修改返回值寄存器RAX。 #### 修改返回值寄存器RAX来跳出循环 我们首先上述目标程序编译构建,然后运行起来: ```bash $ go build -gcflags 'all=-N -l' -o main ./main.go $ ./main pid: 2746680 pid: 2746680 pid: 2746680 pid: 2746680 pid: 2746680 ... ``` 我们需要先借助dlv来帮助我们确定下函数调用loop()时的返回指令地址: ```bash $ dlv attach 2746680 ``` 然后我们需要在main.go:10这行设置断点,这行也就是调用loop()的地方: ```bash $ break main.go:10 Breakpoint 1 set at 0x49b5d4 for main.main() ./fuck/test/main.go:10 ``` 然后执行到断点处: ```bash $ continue > [Breakpoint 1] main.main() ./fuck/test/main.go:10 (hits goroutine(1):1 total:1) (PC: 0x49b5d4) 5: "os" 6: "time" 7: ) 8: 9: func main() { => 10: for loop() { 11: fmt.Println("pid:", os.Getpid()) 12: time.Sleep(time.Second) 13: } 14: } ``` 现在我们需要等这个loop()函数调用返回,我们需要知道返回后的返回地址,并在返回地址处设置断点: ```bash (dlv) disass TEXT main.main(SB) /root/fuck/test/main.go main.go:9 0x49b5c0 493b6610 cmp rsp, qword ptr [r14+ 0x10] main.go:9 0x49b5c4 0f86fb000000 jbe 0x49b6c5 main.go:9 0x49b5ca 55 push rbp main.go:9 0x49b5cb 4889e5 mov rbp, rsp main.go:9 0x49b5ce 4883ec70 sub rsp, 0x70 main.go:10 0x49b5d2 eb00 jmp 0x49b5d4 => main.go:10 0x49b5d4* e807010000 call $main.loop main.go:10 0x49b5d9 8844241f mov byte ptr [rsp+0x1f],al ``` 现在我们知道 `call $main.loop` 后的返回地址为0x49b5d9,现在可以退出dlv并保持tracee运行: ```bash (dlv) exit Would you like to kill the process? [Y/n] n ``` 然后,我们后续使用godbg在这个地址处设置断点,注意我们也没有启用ALSR,所以这个地址是不变的: ```bash godbg attach 2746680 process 2746680 attached succ process 2746680 stopped: true godbg> break 0x49b5d9 godbg> ``` 然后我们需要执行到这个断点处,此处loop()刚刚返回,根据ABI调用约定,RAX中存储着loop()的返回值,我们再通过setreg来修改rax的值为“false”。 ```bash godbg> continue thread 2746680 continued succ thread 2746681 continued succ thread 2746682 continued succ thread 2746683 continued succ thread 2746684 continued succ thread 2746680 status: stopped: trace/breakpoint trap ``` 然后修改寄存器的值: ```bash godbg> pregs Register R15 0x9 Register R14 0xc0000061c0 Register R13 0x20 Register R12 0x7ffe2df6ce18 Register Rbp 0xc0000c6f68 Register Rbx 0x43cdfc Register R11 0x206 Register R10 0x0 Register R9 0x0 Register R8 0x0 Register Rax 0x1 // <= true ... godbg> setreg rax 0x0 // <= false ``` 然后continue恢复执行,观察到恢复执行后有些线程开始退出了,但是也还有继续运行到断点的线程: ```bash godbg> continue warn: thread 2746681 exited warn: thread 2746682 exited warn: thread 2746683 exited ... continue ok ``` 我们结束调试,结束调试时会清理断点并将暂停在断点处的线程rewind PC (PC=PC-1),然后detach,这样被调试进程会恢复执行: ```bash godbg> exit before detached, clearall created breakpoints.warn: thread 3037322 exited ``` 此时,再来观察被调试程序及其输出: ```bash $ ./main pid: 2746680 pid: 2746680 pid: 2746680 pid: 2746680 pid: 2746680 <= 调试器修改了loop()调用的返回值为FALSE,该返回值存储在寄存器RAX $ <= 然后循环条件检测不通过,退出了循环,程序结束 ``` 我们通过调试器篡改函数调用返回值,让程序执行跳出了for循环。 ### 本节小结 本节主要探讨了调试器中修改寄存器数据的功能实现,核心内容包括:通过 `ptrace(PTRACE_SET_REGS,...)`系统调用实现寄存器修改;使用反射机制动态定位和修改特定寄存器字段;结合 `setreg`命令实现通用的寄存器修改功能。本节通过篡改函数返回值寄存器RAX的实例,演示了如何利用寄存器修改来控制程序执行流程,为读者展示了指令级调试中修改程序状态的强大能力。这种技术不仅适用于修改函数返回值,还可以结合栈帧知识修改函数参数和返回地址,为深入的程序调试和逆向分析提供了重要工具。 ================================================ FILE: book/6-develop-inst-debugger/2-process_attach.md ================================================ ## 启动调试:跟踪进程 ### 实现目标:`godbg attach ` 跟踪运行中进程 如果进程已经在运行了,要对其进行调试需要先通过attach操作跟踪进程,待其停止执行后,再执行查看修改数据、控制程序执行的操作。常见的调试器如dlv、gdb等都支持传递pid参数来对运行中的进程进行跟踪调试。 本节我们将实现程序 `godbg attach ` 子命令。 本节示例代码中,godbg将attach到目标进程,此时目标进程会暂停执行。然后我们让godbg休眠几秒钟(我们假定这几秒钟内执行了一些调试动作,如添加断点、执行到断点、查看变量等,然后调试完后结束调试),再detach目标进程,目标进程会恢复执行。 ### 基础知识 #### tracee 首先要进一步明确tracee的概念,虽然我们看上去是对进程进行调试,实际上调试器内部工作时,是对一个一个的线程进行调试。 tracee,指的是被调试的线程,而不是进程。对于一个多线程程序而言,调试期间可能要跟踪部分或者全部线程,没有被跟踪的线程将会继续执行,而被跟踪的线程则受调试器控制。甚至同一个被调试进程中的不同线程,可以由不同的tracer来控制。 注意,这里有几个点需要提前跟大家明确下: - 同一个线程只允许被一个调试器跟踪调试,如果希望启动多个独立的调试器实例对目标线程进行跟踪,操作系统会检测到该线程已经被某个调试器进程跟踪调试中,会拒绝其他调试器实例的ptrace请求。 - 在前后端分离式调试器架构下,也就是说只允许1个debugger backend实例attach被调试线程,但是我们可以启动多个debugger frontend来同时进行并发调试,这部分在第9章允许 multiclient访问debugger backend时会介绍。 - 为了方便调试期间观察各个线程的状态,调试器通常会采用All-stop Mode,即默认跟踪进程中的所有线程。要运行所有线程都运行,要停止所有线程都停止。这种方式更方便调试人员调试。 #### tracer tracer,指的是向tracee发送调试控制命令的调试器进程,准确地说,也是线程。 有时会使用术语ptrace link,实际上是指tracer通过ptrace系统调用(如PTRACE_ATTACH)成功跟踪了tracee,此后tracer就可以向tracee发送各种调试命令。需要注意的是,建立跟踪关系后,tracee期望后续所有的ptrace请求都来自同一个tracer线程,否则会被内核拒绝或行为未定义。因此,调试器(debugger backend)实现时要注意,attach后后续对该tracee的所有ptrace操作都要在主动建立该ptrace link的tracer线程中发起。 这也意味着,同一个线程只允许被同一个调试器(debugger backend)实例跟踪调试。关于这点,我们可以通过如下操作对此进行验证。 #### ptrace attach 实际上ptrace_link是一个linux内核函数,顾名思义,它指的就是tracer attach到tracee后建立了跟踪关系。ptrace link一旦建立后,tracee就只允许接收来自link另一端的tracer的ptrace请求。关于这点,我们可以验证下。 **1)验证1:多个调试器实例attach同一个线程** shell 1中先启动一个预先写好的go程序,它执行for循环: ```bash $ ./goforloop ``` shell 2中通过godbg attach到该goforloop进程,attach成功: ```bash $ godbg attach `pidof goforloop` ``` shell 3中通过godbg再次attach到该goforloop进程,attach报权限失败: ```bash $ godbg attach `pidof goforloop` Error: process 31060 attached error: operation not permitted ``` **2) 验证2:attach成功后通过其他线程发送ptrace请求** 这样也是不被允许的,读者如果感兴趣,可以自行注释掉godbg中的 `runtime.LockOSThread()` 调用,然后重编godbg进行调试活动,执行期间就会收到相关的权限报错信息 No Such Process。 **内核中执行ptrace操作时,内核会进行这样的校验**: file: ./kernel/ptrace.c ```c SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr, unsigned long, data) { ... ret = ptrace_check_attach(child, request == PTRACE_KILL || request == PTRACE_INTERRUPT); if (ret < 0) goto out_put_task_struct; ... out_put_task_struct: put_task_struct(child); } static int ptrace_check_attach(struct task_struct *child, bool ignore_state) { int ret = -ESRCH; // no such process ... if (child->ptrace && child->parent == current) { WARN_ON(child->state == __TASK_TRACED); /* * child->sighand can't be NULL, release_task() * does ptrace_unlink() before __exit_signal(). */ if (ignore_state || ptrace_freeze_traced(child)) ret = 0; } ... return ret } ``` 如果后续ptrace请求来自非ptrace link建立时的tracer,那么ptrace_check_attach操作就会返回错误码 `-ESRCH`。 #### ptrace limits 我们的调试器示例是基于Linux平台编写的,调试能力依赖于Linux ptrace。 通常,如果调试器也是多线程程序,就要注意ptrace的约束,当tracer、tracee建立了跟踪关系后,tracee(被跟踪线程)后续接收到的多个调试命令应该来自同一个tracer(跟踪线程),意味着调试器实现时要将发送调试命令给tracee的task (goroutine) 绑定到tracer对应的特定线程上。 所以,在我们参考dlv等调试器的实现时会发现,发送调试命令的goroutine通常会调用 `runtime.LockOSThread()` 来绑定一个线程,后续ptrace请求均通过这个goroutine、这个thread来发送。 > runtime.LockOSThread(),该函数的作用是将调用该函数的goroutine绑定到该操作系统线程上,意味着该操作系统线程只会用来执行该goroutine上的操作,除非该goroutine调用了runtime.UnLockOSThread()解除这种绑定关系,否则该线程不会用来调度其他goroutine。调用这个函数的goroutine也只能在当前线程上执行,不会被调度器迁移到其他线程。 > > 如果这个goroutine执行结束后退出,绑定的这个线程M也会被销毁。这是当前go runtime设计实现中,除了进程退出时销毁线程之外的唯一一个线程M被创建出来后又销毁的情况。换言之,如果你的程序执行太多阻塞系统调用创建大量线程后,这些线程是不会被运行时主动销毁的。 > > ok,我们来看下这个runtime.LockOSThread()的文档注释,see: > > ```go > package runtime // import "runtime" > > func LockOSThread() > LockOSThread wires the calling goroutine to its current operating system > thread. The calling goroutine will always execute in that thread, and no > other goroutine will execute in it, until the calling goroutine has made as > many calls to UnlockOSThread as to LockOSThread. If the calling goroutine > exits without unlocking the thread, the thread will be terminated. > > All init functions are run on the startup thread. Calling LockOSThread from > an init function will cause the main function to be invoked on that thread. > > A goroutine should call LockOSThread before calling OS services or non-Go > library functions that depend on per-thread state. > ``` 调用了该函数之后,就可以满足tracee对tracer的要求:一旦tracer通过ptrace_attach了某个tracee,后续发送到该tracee的ptrace请求必须来自同一个tracer (tracee、tracer具体指的都是线程)。否则会遇到错误 `-ESRCH (No Such Process)`。 当结束调试时,可以通过ptrace detach操作,让tracee恢复执行。即使不显示detach,操作系统也会代为处理,tracee也可以恢复执行。 #### wait & ptrace r/w 当我们调用了attach之后,attach返回时,tracee有可能还没有停下来,这个时候需要通过wait方法来等待tracee停下来,并获取tracee的状态信息。 此时我们不光可以使用ptrace的其他内存读写操作、寄存器读写操作等来读取tracee的信息,比如读写内存变量值、读写寄存器信息,甚至一些更加高级的用法,比如显示当前tracee的函数调用栈。 ps: 随着后面学习的深入,我们会知道tracee通知调试器从wait4阻塞状态唤醒,而调用wait4的线程不一定是建立ptracelink的tracer线程。 ### 代码实现 当我们通过 `ptrace(PTRACE_ATTACH, pid, ...)` 操作去跟踪一个指定的线程时,内核会给这个目标线程发送一个信号SIGSTOP。 当执行SIGSTOP的信号处理时,内核会执行如下关键操作,效果就是让tracee停下来,并且通知tracer。 ```c do_signal_stop set_special_state(TASK_STOPPED); // 暂停tracee执行 do_notify_parent_cldstop(current, false, notify); // 通知ptracer tracee已经停止 __group_send_sig_info(SIGCHLD, &info, parent); // 给ptracer进程发送SIGCHLD,任意线程都可以处理 __wake_up_parent(tsk, parent); // 唤醒ptracer进程中任意调用了wait4(tracee,)阻塞的线程 ``` 下面是man手册关于ptrace操作attach、detach的说明,大家可以详细了解下: > **PTRACE_ATTACH** > Attach to the process specified in pid, making it a tracee of > the calling process. The tracee is sent a SIGSTOP, but will > not necessarily have stopped by the completion of this call; > > use waitpid(2) to wait for the tracee to stop. See the "At‐ > taching and detaching" subsection for additional information. > > **PTRACE_DETACH** > Restart the stopped tracee as for PTRACE_CONT, but first de‐ > tach from it. Under Linux, a tracee can be detached in this > way regardless of which method was used to initiate tracing. 完整的测试代码实现请参考:`golang-debugger-lessons/2_process_attach`。考虑到读者是刚刚接触调试器开发,建议优先学习 `golang-debugger-lessons` 中的简化示例代码,这部分示例代码每个目录对应本章一个小节。待掌握基本调试原理和概念后再学习 `hitzhangjie/godbg` 中的完整实现。 file: main.go ```go package main import ( "fmt" "os" "os/exec" "runtime" "strconv" "syscall" "time" ) const ( usage = "Usage: go run main.go exec " cmdExec = "exec" cmdAttach = "attach" ) func main() { // issue: https://github.com/golang/go/issues/7699 // // 为什么syscall.PtraceDetach, detach error: no such process? // 因为ptrace请求应该来自相同的tracer线程, // // ps: 如果恰好不是,可能需要对tracee的状态显示进行更复杂的处理,需要考虑信号? // 目前看系统调用传递的参数是这样。 runtime.LockOSThread() if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "%s\n\n", usage) os.Exit(1) } cmd := os.Args[1] switch cmd { case cmdExec: prog := os.Args[2] // run prog progCmd := exec.Command(prog) buf, err := progCmd.CombinedOutput() fmt.Fprintf(os.Stdout, "tracee pid: %d\n", progCmd.Process.Pid) if err != nil { fmt.Fprintf(os.Stderr, "%s exec error: %v, \n\n%s\n\n", err, string(buf)) os.Exit(1) } fmt.Fprintf(os.Stdout, "%s\n", string(buf)) case cmdAttach: pid, err := strconv.ParseInt(os.Args[2], 10, 64) if err != nil { fmt.Fprintf(os.Stderr, "%s invalid pid\n\n", os.Args[2]) os.Exit(1) } // check pid if !checkPid(int(pid)) { fmt.Fprintf(os.Stderr, "process %d not existed\n\n", pid) os.Exit(1) } // attach err = syscall.PtraceAttach(int(pid)) if err != nil { fmt.Fprintf(os.Stderr, "process %d attach error: %v\n\n", pid, err) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d attach succ\n\n", pid) // wait var ( status syscall.WaitStatus rusage syscall.Rusage ) _, err = syscall.Wait4(int(pid), &status, syscall.WSTOPPED, &rusage) if err != nil { fmt.Fprintf(os.Stderr, "process %d wait error: %v\n\n", pid, err) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d wait succ, status:%v, rusage:%v\n\n", pid, status, rusage) // detach fmt.Printf("we're doing some debugging...\n") time.Sleep(time.Second * 10) // MUST: call runtime.LockOSThread() first err = syscall.PtraceDetach(int(pid)) if err != nil { fmt.Fprintf(os.Stderr, "process %d detach error: %v\n\n", pid, err) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d detach succ\n\n", pid) default: fmt.Fprintf(os.Stderr, "%s unknown cmd\n\n", cmd) os.Exit(1) } } // checkPid check whether pid is valid process's id // // On Unix systems, os.FindProcess always succeeds and returns a Process for // the given pid, regardless of whether the process exists. func checkPid(pid int) bool { out, err := exec.Command("kill", "-s", "0", strconv.Itoa(pid)).CombinedOutput() if err != nil { panic(err) } // output error message, means pid is invalid if string(out) != "" { return false } return true } ``` 这里的程序逻辑也比较简单: 1. 程序运行时,首先检查命令行参数, - `godbg attach `,至少有3个参数,如果参数数量不对,直接报错退出; - 接下来校验第2个参数,如果是无效的subcmd,也直接报错退出; - 如果是attach,那么pid参数应该是个整数,如果不是也直接退出; 2. 参数正常情况下,开始校验pid进程是否存在,存在则开始尝试attach到tracee,建立ptrace link; 3. attach之后,tracee并不一定立即就会停下来,需要wait来获取其状态变化情况; 4. 等tracee停下来之后,我们休眠10s钟,假定此时自己正在执行些调试操作; 5. 10s钟之后调试结束,tracer尝试detach tracee,解除ptrace link,让tracee继续恢复执行。 > 我们在Linux平台上实现时,需要考虑Linux平台本身的问题,具体包括: > > - 检查pid是否对应着一个有效的进程,通常会通过 `exec.FindProcess(pid)`来检查,但是在Unix平台下,这个函数总是返回OK,所以是行不通的。因此我们借助了 `kill -s 0 pid`这一比较经典的做法来检查pid合法性。 > - tracer、tracee进行detach操作的时候,我们是用了ptrace系统调用,这个也和平台有关系,如Linux平台下的man手册有说明,必须确保一个tracee的所有的ptrace requests来自相同的tracer线程,实现时就需要注意这点。 ### 代码测试 下面是一个测试示例,帮助大家进一步理解attach、detach的作用。 我们先在bash启动一个命令,让其一直运行,然后获取其pid,并让godbg attach将其挂住,观察程序的暂停、恢复执行。 比如,我们在bash里面先执行以下命令,它会每隔1秒打印一下当前的pid: ```bash $ while [ 1 -eq 1 ]; do t=`date`; echo "$t pid: $$"; sleep 1; done Sat Nov 14 14:29:04 UTC 2020 pid: 1311 Sat Nov 14 14:29:06 UTC 2020 pid: 1311 Sat Nov 14 14:29:07 UTC 2020 pid: 1311 Sat Nov 14 14:29:08 UTC 2020 pid: 1311 Sat Nov 14 14:29:09 UTC 2020 pid: 1311 Sat Nov 14 14:29:10 UTC 2020 pid: 1311 Sat Nov 14 14:29:11 UTC 2020 pid: 1311 Sat Nov 14 14:29:12 UTC 2020 pid: 1311 Sat Nov 14 14:29:13 UTC 2020 pid: 1311 Sat Nov 14 14:29:14 UTC 2020 pid: 1311 ==> 14s ^C ``` 然后我们执行命令: ```bash $ go run main.go attach 1311 process 1311 attach succ process 1311 wait succ, status:4991, rusage:{{12 607026} {4 42304} 43580 0 0 0 375739 348 0 68224 35656 0 0 0 29245 153787} we're doing some debugging... ==> 这里sleep 10s ``` 执行完上述命令后,回来看shell命令的输出情况,可见其被挂起了,等了10s之后又继续恢复执行,说明detach之后又可以继续执行。 ``` Sat Nov 14 14:29:04 UTC 2020 pid: 1311 Sat Nov 14 14:29:06 UTC 2020 pid: 1311 Sat Nov 14 14:29:07 UTC 2020 pid: 1311 Sat Nov 14 14:29:08 UTC 2020 pid: 1311 Sat Nov 14 14:29:09 UTC 2020 pid: 1311 Sat Nov 14 14:29:10 UTC 2020 pid: 1311 Sat Nov 14 14:29:11 UTC 2020 pid: 1311 Sat Nov 14 14:29:12 UTC 2020 pid: 1311 Sat Nov 14 14:29:13 UTC 2020 pid: 1311 Sat Nov 14 14:29:14 UTC 2020 pid: 1311 ==> at 14s, attached and stopped Sat Nov 14 14:29:24 UTC 2020 pid: 1311 ==> at 24s, detached and continued Sat Nov 14 14:29:25 UTC 2020 pid: 1311 Sat Nov 14 14:29:26 UTC 2020 pid: 1311 Sat Nov 14 14:29:27 UTC 2020 pid: 1311 Sat Nov 14 14:29:28 UTC 2020 pid: 1311 Sat Nov 14 14:29:29 UTC 2020 pid: 1311 ^C ``` 然后我们再看下我们调试器的输出,可见其attach、暂停、detach逻辑,都是正常的。 ```bash $ go run main.go attach 1311 process 1311 attach succ process 1311 wait succ, status:4991, rusage:{{12 607026} {4 42304} 43580 0 0 0 375739 348 0 68224 35656 0 0 0 29245 153787} we're doing some debugging... process 1311 detach succ ``` ### 问题探讨 为了让读者能快速掌握核心调试原理,示例里我们有意简化了示例,示例中被调试进程是一个单线程程序,如果是一个多线程程序结果会不会不一样呢?会! #### 问题:多线程程序attach后仍在运行? 假如我使用下面的go程序做为被调试程序,结果发现执行了 `godbg attach `之后程序还在执行,这是为什么呢? ```go import ( "fmt" "time" "os" ) func main() { for { time.Sleep(time.Second) fmt.Println("pid:", os.Getpid()) } } ``` 有几个go语言相关的细节需要先说明: 1. go程序天然是多线程程序,sysmon、gc等等都可能会用到独立线程,我们执行 `attach ` 只是跟踪了进程中的主线程,其他的线程仍然是没有被调踪的,是可以继续执行的。 2. go运行时采用GMP调度机制,同一个goroutine在生命周期能可能会在多个thread上先后执行一部分代码逻辑,比如某个goroutine执行阻塞系统调用后,会创建出新的线程,如果系统调用返回后,goroutine也要恢复执行,此时有可能会去找之前的thread,但是根据调度负载情况、原先M、原先P空闲情况,非常有可能这个goroutine会在另一个thread中继续执行,而该thread没有被调试器跟踪,依然可以继续执行。 3. 具体到我们示例中,ptrace指定的pid到底是主线程pid,main.main是main goroutine的入口函数,但是main goroutine却不一定在main thread中执行。 附录《go runtime: go程序启动流程》中对go程序的启动流程做了分析,可以帮读者朋友打消这里runtime.main、main.main 在 main goroutine、main thread 中执行细节的一些疑虑。**go程序中函数main.main是由main goroutine来执行的,但是main goroutine并没有和main thread存在任何默认的绑定关系**。所以认为main.main一定运行在pid对应的主线程上是错误的(联想GMP调度机制,main goroutine一开始就不一定运行在主线程上,而且也没有上述提及的runtime.LockOSThread()会一直保证运行在特定线程上)! 在Linux下,线程其实是通过轻量级进程(LWP)来实现的,这里的ptrace参数pid实际上是主线程对应的LWP的pid。只对这个pid进行ptrace attach操作,作用是,这个pid对应的线程会被跟踪,但是进程中的其他线程并没有被跟踪,它们仍然可以继续执行。这就是为什么我们自己写个go程序验证下attach功能,会发现被调试程序仍然在不停输出,因为tracer并没有在main.main内部设置断点,执行该函数main.main的main goroutine可能由其他未被跟踪的线程执行。 #### 问题:go进程中线程是如何创建出来的? 一个多线程程序,程序可以通过执行 “**系统调用clone+选项CLONE_THREAD**” 来创建新线程,新线程的pid `os.Getpid()` 和 从属的进程拥有相同的pid。 对于go语言,go运行时在初始化时、后续执行期间需要创建新线程时,会通过 `runtime.newosproc` -> `clone+cloneFlags` 来创建线程: ```go cloneFlags = _CLONE_VM | /* share memory */ _CLONE_FS | /* share cwd, etc */ _CLONE_FILES | /* share fd table */ _CLONE_SIGHAND | /* share sig handler table */ _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */ _CLONE_THREAD /* revisit - okay for now */ func newosproc(mp *m) { stk := unsafe.Pointer(mp.g0.stack.hi) ... ret := retryOnEAGAIN(func() int32 { r := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart))) // clone returns positive TID, negative errno. // We don't care about the TID. if r >= 0 { return 0 } return -r }) ... } ``` > ps: 关于clone选项的更多作用,您可以通过查看man手册 `man 2 clone`来了解。 #### 问题:想确保执行main.main的线程停下来? 在不考虑main.main->forloop位置设断点的情况下,如果只是想让所有线程在attach时都能尽快停下来,需要采用All-stop Mode。 调试器需要在attach主线程成功后,枚举进程包含的所有线程,并对它们逐一进行ptrace attach操作。Linux下可以列出 `/proc//task` 下的所有线程的pid (LWP的pid,而非进程内线程编号tid)。 ```go func (p *DebuggedProcess) loadThreadList() ([]int, error) { threadIDs := []int{} tids, _ := filepath.Glob(fmt.Sprintf("/proc/%d/task/*", p.Process.Pid)) for _, tidpath := range tids { tidstr := filepath.Base(tidpath) tid, err := strconv.Atoi(tidstr) if err != nil { return nil, err } threadIDs = append(threadIDs, tid) } return threadIDs, nil } ``` 对进程内每个线程逐个执行ptrace attach,所有线程也就都停下来了。All-stop Mode很重要,这里也算是提前了解下。 > 调试活动通常是带有目的性的调试,而不是漫无目的地闲逛,这样调试效率才会高。调试很重要的一点就是,在可疑代码处先提前设置好断点,执行到此位置的线程自然会停下来。如果没有提前设置好断点,可疑位置代码已经执行过了,就只能重新开始调试会话了。对于多线程程序,为了方便观察多个线程的运行情况甚至是线程间的交互情况,通常这些线程要么全部运行要么全部停止。 #### 问题:如何判断进程是否是多线程程序? 如何判断目标进程是否是多线程程序呢?有两种简单的办法帮助判断。 - `top -H -p pid` `-H`选项将列出进程pid下的线程列表,以下进程5293下有4个线程,Linux下线程是通过轻量级进程实现的,PID列为5293的轻量级进程为主线程。 ```bash $ top -H -p 5293 ........ PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 5293 root 20 0 702968 1268 968 S 0.0 0.0 0:00.04 loop 5294 root 20 0 702968 1268 968 S 0.0 0.0 0:00.08 loop 5295 root 20 0 702968 1268 968 S 0.0 0.0 0:00.03 loop 5296 root 20 0 702968 1268 968 S 0.0 0.0 0:00.03 loop ``` top展示信息中列S表示进程状态,常见的取值及含义如下: ```bash 'D' = uninterruptible sleep 'R' = running 'S' = sleeping 'T' = traced or stopped 'Z' = zombie ``` 通过状态 **'T'** 可以识别多线程程序中哪些线程正在被调试跟踪。 - `ls /proc//task` ```bash $ ls /proc/5293/task/ 5293/ 5294/ 5295/ 5296/ ``` Linux下/proc是一个虚拟文件系统,它里面包含了系统运行时的各种状态信息,以下命令可以查看到进程5293下的线程。和top展示的结果是一样的。 #### 问题:syscall.Wait4的参数说明 Linux系统有多个等待进程状态改变的系统调用,它们有一些使用、功能上的细微差别,我们这里使用syscall.Wait4刚好对应着Linux系统调用wait4,详细的使用说明可以参考man手册。 man手册说明中强相关的部分,如下所示: man 2 wait4 > **Name** > > *wait3, wait4 - wait for process to change state, BSD style* > > **SYNOPSIS** > > pid_t wait3(int *wstatus, int options, > struct rusage *rusage); > > pid_t wait4(pid_t pid, int *wstatus, int options, > struct rusage *rusage); > > **Description** > > **These functions are obsolete; use waitpid(2) or waitid(2) in new programs.** > > The wait3() and wait4() system calls are similar to waitpid(2), but additionally return resource usage information about the child in the structure pointed to by rusage. man 2 waitpid > **Name** > > wait, waitpid, waitid - wait for process to change state > > **SYNOPSIS** > > pid_t wait(int *wstatus); > > pid_t waitpid(pid_t pid, int *wstatus, int options); > > int waitid(idtype_t idtype, id_t id, siginfo_t*infop, int options); > /* This is the glibc and POSIX interface; see > NOTES for information on the raw system call. */ > > **SYNOPSIS** > > All of these system calls are used to wait for state changes in a child of the calling process, and obtain information about the child whose state has changed. A state change is considered to be: the child terminated; > the child was stopped by a signal; or the child was resumed by a signal. In the case of a terminated child, performing a wait allows the system to release the resources associated with the child; if a wait is not per‐ > formed, then the terminated child remains in a "zombie" state (see NOTES below). > > If a child has already changed state, then these calls return immediately. Otherwise, they block until either a child changes state or a signal handler interrupts the call (assuming that system calls are not automati‐ > cally restarted using the SA_RESTART flag of sigaction(2)). In the remainder of this page, a child whose state has changed and which has not yet been waited upon by one of these system calls is termed waitable. > > wait() and waitpid() > The wait() system call suspends execution of the calling process until one of its children terminates. The call wait(&wstatus) is equivalent to: > > waitpid(-1, &wstatus, 0); > > The waitpid() system call suspends execution of the calling process until a child specified by pid argument has changed state. By default, waitpid() waits only for terminated children, but this behavior is modifiable via > the options argument, as described below. > > The value of pid can be: > > - \<-1: meaning wait for any child process whose process group ID is equal to the absolute value of pid. > - -1: meaning wait for any child process. > - 0: meaning wait for any child process whose process group ID is equal to that of the calling process. > - \>0: meaning wait for the child whose process ID is equal to the value of pid. > > The value of options is an OR of zero or more of the following constants: > > - WNOHANG: ... blabla > - WUNTRACED: ... blabla > - WCONTINUED: ... blabla > > (For Linux-only options, see below.) > > - WIFSTOPPED: returns true if the child process was stopped by delivery of a signal; this is possible only if the call was done using WUNTRACED or when the child is being traced (see ptrace(2)). > - ... blabla ### 本节小结 attach操作是调试器进行调试的第一步。本节不仅介绍了如何attach到目标进程,还详细阐述了ptrace link的概念及其限制,并从内核层面分析了这些限制的原因。同时,我们还讨论了Go语言作为原生多线程程序,如何借助All-stop Mode实现对多个线程的同步跟踪与观察。 针对attach目标进程后,main.main中的循环依然在执行的现象,我们结合Go的协程编程模型,深入讲解了其多线程调度机制,并结合Go的启动流程,解释了初学者常见的GMP调度疑惑(例如main.main不一定运行在主线程上)。此外,我们还介绍了如何枚举进程中的线程列表并实现All-stop Mode,以及如何判断一个进程是否为多线程程序。 通过这些内容,我们梳理并解答了调试器实现过程中一些基础但至关重要的问题,为后续深入理解调试器的实现原理打下了坚实的基础。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/1-trace_old_threads.md ================================================ ## 调试多线程程序 - 跟踪已创建线程 ### 实现目标:枚举并跟踪已创建的线程 在我们准备开始调试时,有些线程就已经被创建并在运行了,如何枚举并跟踪进程中已有的线程呢?以dlv为例,`dlv attach ` 之后会立即枚举并跟踪所有线程,包括已经存在的线程,以及将来可能创建的线程。 除了跟踪所有线程,dlv调试器还提供切换线程上下文的能力。比如当调试人员希望查看某个特定线程的状态时,可以通过 `dlv>threads` 查看线程列表,然后使用 `dlv> thread ` 来切换到特定线程的上下文,这样后续的寄存器查看、变量打印等命令就会显示该线程上下文下的值。另外,dlv也提供了查看goroutines列表并切换goroutine上下文的能力。 Go程序天然是多线程程序,并且提供给开发者的是面向goroutine的并发控制能力,而非面向线程的。Go语言GMP调度设计使得进程中创建的goroutines会被运行时调度到多个线程上执行,即使是同一个goroutine也可能在多个线程上执行。这给后续面向Go程序的断点管理、执行控制机制也带来了一些挑战,对Go运行时理解不够深入,调试器对Go中线程、协程的执行控制就很难做到位,是不可能开发出达到应用水准的调试器的。 >ps: 比如我们在两个地址处addr1、addr2分别设置了断点,但是从某个线程命中addr1处断点停下后,我们显示执行continue,再到某个线程执行到addr2处断点停下,你希望哪个线程停在地址addr2出呢?任意一个线程,当前跟踪的线程,还是当前线程正在执行的goroutine?如何实现直接决定了调试体验、调试效率。 OK,收回来,本节我们先聚焦如何跟踪进程中已经创建的所有线程。 ### 基础知识 要跟踪进程中已经创建的线程,我们首先要能够获取进程内所有线程,然后才能逐个跟踪。那如何获取进程内所有线程呢? 熟悉Linux系统的同学,很自然会想到执行 `top -H -p ` 可以列出指定进程内所有线程信息,但是top输出信息繁杂,通过解析top输出拿到所有线程id的方式并不太方便。Linux虚拟文件系统 `/proc` 提供了更方便的方式,只要遍历 `/proc//task` 下的所有目录名即可。Linux内核会在上述目录下维护线程对应的任务信息,每个目录的名字是一个线程LWP的pid,每个目录内容包含了这个任务的一些信息。 举个例子,我们看下pid=1的进程的一些信息: ```bash root🦀 ~ $ ls /proc/1/task/1/ arch_status clear_refs environ io mounts oom_score_adj sched stack uid_map attr cmdline exe limits net pagemap schedstat stat wchan auxv comm fd maps ns personality setgroups statm cgroup cpuset fdinfo mem oom_adj projid_map smaps status children cwd gid_map mountinfo oom_score root smaps_rollup syscall ``` 虚拟文件系统 `/proc` 是内核提供的一个程序与内核交互的接口,可以读可以写,这并不是什么野路子,而是非常地道的方法,相比如top、vmstat、cgroup等等常见工具也是通过访问 /proc 来达成相关功能。 OK,对我们这个调试器而言,目前我们只需要知道: - 要枚举进程的所有线程,我们就遍历 `/proc//task` 下的目录; - 要读取其完整的指令数据时,我们就读取目录下的 exe 文件; - 要读取其启动参数数据,方便重启被调试进程、重启调试时,我们就读取目录下的 cmdline 文件; ps:OK,这个目录 `/proc//task` 下还有很多其他目录和文件,我们可以先不关注。 当我们拿到了进程内所有线程id列表之后,就可以逐个跟踪这些线程了,前面我们讲过如何跟踪单个线程,现在的工作量只是for循环遍历这些线程id,然后逐个跟踪而已。 ### 设计实现 #### 准备测试程序 首先为了测试方便,我们先准备一个testdata/fork_noquit.c的测试程序,跟前一小节的testdata/fork.c类似,它会创建线程并且打印pid、tid信息,不同的是,这里的线程永远不会退出,主要目的是给我们调试留下更充足的时间,避免因为线程退出导致后续跟踪线程失败。 ```c #include #include #include #include #include pid_t gettid(void); void *threadfunc(void *arg) { printf("process: %d, thread: %u\n", getpid(), syscall(SYS_gettid)); while (1) { sleep(1); } } int main() { printf("process: %d, thread: %u\n", getpid(), syscall(SYS_gettid)); pthread_t tid; for (int i = 0; i < 100; i++) { if (i % 10 == 0) { int ret = pthread_create(&tid, NULL, threadfunc, NULL); if (ret != 0) { printf("pthread_create error: %d\n", ret); exit(-1); } } sleep(1); } while(1) { sleep(1); } } ``` 这个程序可以这样编译 `gcc -o fork_noquit fork_noquit.c -lpthread`,然后运行 `./fork_noquit` 观察其输出。 #### 调试器逻辑实现 这部分实现代码,详见 [hitzhangjie/golang-debugger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons) / 21_trace_old_threads。 然后我们再来看看调试器部分的代码逻辑,这里主要是为了演示如何待调试进程中已经创建的线程,以及如何去跟踪它们,如何从跟踪这个线程切换为跟踪另一个线程。 程序核心逻辑如下: - 我们执行 `./21_trace_old_threads $(pidof fork_noquit)`,此时会检查进程是否存在 - 然后回枚举进程中已创建的线程,方式就是通过读取 /proc 下的信息,然后输出所有线程id - 然后提示用户输入一个希望跟踪的目标线程id,输入后开始跟踪这个线程, ps:如果已经有一个调试器实例在跟踪目标进程了,需要先停止,然后再重新启动调试器实例跟踪目标进程 (否则,内核会返回权限错误)。 file: 21_trace_old_threads/main.go ```go package main import ( "fmt" "os" "os/exec" "runtime" "strconv" "syscall" ) var usage = `Usage: go run main.go args: - pid: specify the pid of process to attach ` func main() { runtime.LockOSThread() if len(os.Args) != 2 { fmt.Println(usage) os.Exit(1) } fmt.Fprintf(os.Stdout, "===step1===: check target process existed or not\n") // check pid is valid process's id pid, err := strconv.Atoi(os.Args[1]) if err != nil { panic(err) } if !checkPid(int(pid)) { fmt.Fprintf(os.Stderr, "process %d not existed\n\n", pid) os.Exit(1) } fmt.Fprintf(os.Stdout, "===step2===: enumerate created threads by reading /proc\n") // enumerate all threads by reading /proc//task/ threads, err := readThreadIDs(pid) if err != nil { panic(err) } fmt.Fprintf(os.Stdout, "threads: %v\n", threads) fmt.Fprintf(os.Stdout, "===step3===: attach to all threads for tracing\n") // attach to all threads for tracing attachedThreads := make(map[int]bool) for _, tid := range threads { err := syscall.PtraceAttach(tid) if err != nil { fmt.Fprintf(os.Stderr, "thread %d attach error: %v\n", tid, err) continue } attachedThreads[tid] = true fmt.Fprintf(os.Stdout, "thread %d attached successfully\n", tid) } fmt.Fprintf(os.Stdout, "attached to %d threads total\n\n", len(attachedThreads)) // wait for all attached threads to stop fmt.Fprintf(os.Stdout, "===step4===: wait for all threads to stop\n") for tid := range attachedThreads { var status syscall.WaitStatus var rusage syscall.Rusage _, err := syscall.Wait4(tid, &status, 0, &rusage) if err != nil { fmt.Fprintf(os.Stderr, "thread %d wait error: %v\n", tid, err) continue } if !status.Stopped() { fmt.Fprintf(os.Stderr, "thread %d not stopped\n", tid) continue } fmt.Fprintf(os.Stdout, "thread %d stopped\n", tid) } // show current state of all traced threads fmt.Fprintf(os.Stdout, "\n===step5===: show current state of all traced threads\n") for tid := range attachedThreads { regs := syscall.PtraceRegs{} if err := syscall.PtraceGetRegs(tid, ®s); err != nil { fmt.Fprintf(os.Stderr, "thread %d get regs fail: %v\n", tid, err) continue } fmt.Fprintf(os.Stdout, "thread %d stopped at %0x\n", tid, regs.PC()) } fmt.Fprintf(os.Stdout, "\nAll threads are now being traced. Use Ctrl+C to exit.\n") } // checkPid check whether pid is valid process's id // // On Unix systems, os.FindProcess always succeeds and returns a Process for // the given pid, regardless of whether the process exists. func checkPid(pid int) bool { out, err := exec.Command("kill", "-s", "0", strconv.Itoa(pid)).CombinedOutput() if err != nil { panic(err) } // output error message, means pid is invalid if string(out) != "" { return false } return true } // reads all thread IDs associated with a given process ID. func readThreadIDs(pid int) ([]int, error) { dir := fmt.Sprintf("/proc/%d/task", pid) files, err := os.ReadDir(dir) if err != nil { return nil, err } var threads []int for _, file := range files { tid, err := strconv.Atoi(file.Name()) if err != nil { // Ensure that it's a valid positive integer continue } threads = append(threads, tid) } return threads, nil } ``` ### 代码测试 1、先看看testdata/fork_noquit.c,这个程序每隔一段时间就创建一个pthread线程出来。主线程、其他线程创建出来后都会打印该线程对应的pid、tid(这里的tid就是对应的lwp的pid)。 > ps: fork_noquit.c 和 fork.c 的区别就是每个线程都会不停sleep(1) 永远不会退出,这么做的目的就是我们跑这个测试用时比较久,让线程不退出可以避免我们输入线程id执行attach thread 或者 switch thread1 to thread2 时出现线程已退出导致失败的情况。 下面执行该程序等待被调试器调试: ```bash zhangjie🦀 testdata(master) $ ./fork_noquit process: 136593, thread: 136593 process: 136593, thread: 136594 process: 136593, thread: 137919 process: 136593, thread: 139891 process: 136593, thread: 140428 ... ``` 2、此时我们检查上述测试程序的线程运行情况,可以看到线程状态都是 S,表示Sleep,因为线程一直在做 `while(1) {sleep(1);}` 这个操作,处于sleep状态很好理解。 ```bash $ top -H -p `pidof fork_noquit` top - 20:25:47 up 1 day, 5:20, 3 users, load average: 0.29, 0.50, 0.62 Threads: 11 total, 0 running, 11 sleeping, 0 stopped, 0 zombie ... PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 136593 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 136594 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 137919 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 139891 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 140428 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 140765 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 141267 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 141548 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 141801 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 143438 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit 144174 root 20 0 90680 1104 1000 S 0.0 0.0 0:00.00 fork_noquit ... ``` 3、现在我们执行 ./21_trace_old_threads `pidof fork_noquit` 来跟踪fork_noquit程序内创建的所有线程。可以看到,上述测试输出了fork_noquit程序内已经创建的线程pid列表,然后逐一attach跟踪这些线程,并输出了每个线程当前暂停的地址。 ```bash zhangjie🦀 21_trace_old_threads(master) $ ./21_trace_old_threads `pidof fork_noquit` ===step1===: check target process existed or not ===step2===: enumerate created threads by reading /proc threads: [136593 136594 137919 139891 140428 140765 141267 141548 141801 143438 144174] ===step3===: attach to all threads for tracing thread 136593 attached successfully thread 136594 attached successfully thread 137919 attached successfully thread 139891 attached successfully thread 140428 attached successfully ... attached to 11 threads total ===step4===: wait for all threads to stop thread 136593 stopped thread 136594 stopped thread 144174 stopped thread 140765 stopped thread 141267 stopped ... ===step5===: show current state of all traced threads thread 141801 stopped at 7f85f5783098 thread 143438 stopped at 7f85f5783098 thread 137919 stopped at 7f85f5783098 thread 139891 stopped at 7f85f5783098 thread 140428 stopped at 7f85f5783098 ... All threads are now being traced. Use Ctrl+C to exit. ``` 4、现在我们继续运行 `top -H -p $(pidof fork_noquit` 来观察线程状态变化。可以看到进程内所有线程的状态从 S 变成了 t,表示线程现在正在被调试器调试(traced状态)。 ```bash $ top -H -p `pidof fork_noquit` top - 20:30:40 up 1 day, 5:18, 3 users, load average: 0.34, 0.56, 0.65 Threads: 11 total, 0 running, 0 sleeping, 11 stopped, 0 zombie ... PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND 136593 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 136594 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 137919 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 139891 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 140428 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 140765 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 141267 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 141548 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 141801 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 143438 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit 144174 root 20 0 90680 1104 1000 t 0.0 0.0 0:00.00 fork_noquit ``` 5、最后ctrl+c杀死 ./21_trace_old_threads 进程,然后我们继续观察线程的状态,会发现从t变为S。此时调试程序21_trace_old_threads结束前并没有显示detach,但是内核会帮忙做些善后的工作,即让tracer跟踪的tracee恢复执行。 ### 本节小结 本节主要探讨了调试多线程程序时如何跟踪已经创建的线程这一核心问题。通过分析Linux系统提供的 `/proc` 虚拟文件系统接口,我们掌握了枚举进程中所有线程的方法:遍历 `/proc//task` 目录下的所有子目录名即可获取所有线程ID。在此基础上,我们实现了完整的线程跟踪机制,包括进程存在性检查、线程枚举、批量attach跟踪、等待线程停止以及显示线程状态等关键步骤。 本节的核心要点包括:利用 `/proc//task` 目录枚举进程内所有线程;通过 `syscall.PtraceAttach` 批量跟踪多个线程;使用 `syscall.Wait4` 等待所有被跟踪线程停止;通过 `syscall.PtraceGetRegs` 获取线程寄存器状态。通过实际测试验证,我们成功实现了对多线程程序的完整跟踪,所有线程状态从Sleep变为Traced,证明了实现的正确性。本节内容为读者理解多线程调试的核心机制提供了实践基础,为后续学习更复杂的调试功能做好了准备。下一节我们将探讨如何自动跟踪进程内后续新创建的线程。 ps: Go程序的GMP调度机制使得线程与goroutine的映射关系更加复杂,这为面向Go程序的调试器开发带来了额外的挑战。这部分内容我们将在第九章符号级调试器开发部分进一步探讨。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/2-trace_new_threads.md ================================================ ## 调试多线程程序 - 跟踪新创建线程 ### 实现目标:感知新线程创建并跟踪 进程执行过程中有可能会创建新线程,尤其是像Go程序这样,为了充分利用多核CPU资源,会自动创建新线程来执行goroutine。当某些goroutine执行阻塞型系统调用导致线程阻塞时,Go运行时为了维持GMP调度的正常运转还会创建新的线程来执行goroutines。在Go进程初始化时,也会创建专门的线程来执行sysmon任务,轮询netpoller、timer、强制GC等任务 …… OK,Go语言虽然是面向goroutine的并发控制,本质上还是依赖线程,依赖操作系统对线程的调度控制能力,然后才是GMP中work-stealing的方式线程执行goroutine的逻辑。 说这么多,只是为了强调Go进程执行过程中,可能会随时创建一些新线程出来。作为调试器,我们需要具备感知新线程创建、主动跟踪的能力。我们需要了解线程是什么,Linux是如何创建线程的,Go运行时是如何创建线程的,有那些系统层面的支持能够方便我们感知新线程创建了,并对新线程的执行进行即时的跟踪控制。本节我们就来看看如何实现这个目标。 ### 基础知识 #### 线程是如何创建的 newosproc创建一个新的线程(newproc创建一个新的goroutine),是通过 `clone` 系统调用来完成的,注意看cloneFlags以及clone操作实现。 ```go // clone创建线程时的克隆参数 const ( cloneFlags = _CLONE_VM | /* share memory */ _CLONE_FS | /* share cwd, etc */ _CLONE_FILES | /* share fd table */ _CLONE_SIGHAND | /* share sig handler table */ _CLONE_SYSVSEM | /* share SysV semaphore undo lists (see issue #20763) */ _CLONE_THREAD /* revisit - okay for now */ ) // 创建一个新的线程 func newosproc(mp *m) { stk := unsafe.Pointer(mp.g0.stack.hi) /* * note: strace gets confused if we use CLONE_PTRACE here. */ if false { print("newosproc stk=", stk, " m=", mp, " g=", mp.g0, " clone=", abi.FuncPCABI0(clone), " id=", mp.id, " ostk=", &mp, "\n") } // Disable signals during clone, so that the new thread starts // with signals disabled. It will enable them in minit. var oset sigset sigprocmask(_SIG_SETMASK, &sigset_all, &oset) ret := clone(cloneFlags, stk, unsafe.Pointer(mp), unsafe.Pointer(mp.g0), unsafe.Pointer(abi.FuncPCABI0(mstart))) sigprocmask(_SIG_SETMASK, &oset, nil) if ret < 0 { print("runtime: failed to create new OS thread (have ", mcount(), " already; errno=", -ret, ")\n") if ret == -_EAGAIN { println("runtime: may need to increase max user processes (ulimit -u)") } throw("newosproc") } } //go:noescape func clone(flags int32, stk, mp, gp, fn unsafe.Pointer) int32 ``` 进程下的线程共享进程打开的某些资源,通过上述cloneFlags也可以看出一点端倪。操作系统设计上,进程是资源分配单位,线程则是最小的调度单位。以Linux为例,不管是进程还是线程,它们都由对应的task_struct结构来描述,并作为sched_entity在内核任务调度器中进行调度。ps: 关于Linux下的任务调度,感兴趣您可以阅读我的博客 [Linux内核](https://www.hitzhangjie.pro/categories/linux%E5%86%85%E6%A0%B8/) 任务调度相关内容。 上述clone函数在amd64架构下的实现,详见 see go/src/runtime/sys_linux_amd64.s: ```go // int32 clone(int32 flags, void *stk, M *mp, G *gp, void (*fn)(void)); TEXT runtime·clone(SB),NOSPLIT,$0 MOVL flags+0(FP), DI // 准备系统调用参数 MOVQ stk+8(FP), SI ... // Copy mp, gp, fn off parent stack for use by child. // Careful: Linux system call clobbers CX and R11. MOVQ mp+16(FP), R13 MOVQ gp+24(FP), R9 MOVQ fn+32(FP), R12 ... MOVL $SYS_clone, AX // clone系统调用号 syscall // 执行系统调用 // In parent, return. CMPQ AX, $0 JEQ 3(PC) MOVL AX, ret+40(FP) // 父进程,返回clone出的新线程的tid RET // In child, on new stack. MOVQ SI, SP // If g or m are nil, skip Go-related setup. CMPQ R13, $0 // m JEQ nog2 CMPQ R9, $0 // g JEQ nog2 // Initialize m->procid to Linux tid MOVL $SYS_gettid, AX SYSCALL MOVQ AX, m_procid(R13) // In child, set up new stack get_tls(CX) MOVQ R13, g_m(R9) MOVQ R9, g(CX) MOVQ R9, R14 // set g register CALL runtime·stackcheck(SB) nog2: // Call fn. This is the PC of an ABI0 function. CALL R12 // 新线程,初始化相关的gmp调度,开始执行线程函数mstart, // clone参数中有个 abi.FuncPCABI0(mstart) ... ``` 由此可知,其实只要tracee执行系统调用clone时,内核给我们一个通知就可以了。 #### 向内核注册clone跟踪动作 **方法1**:通过 `syscall.PtraceSyscall(pid, signal)` 检查有无clone被调用事件 这样tracee执行系统调用clone时,在enter syscall clone、exit syscall clone的位置会停下来,方便我们做点调试方面的工作,我们就可以读取此时RAX寄存器的值来判断当前系统调用号是不是 `__NR_clone` ,如果是,那说明执行了系统调用clone,我们就可以借此判断创建了一个新的线程。同样的可以在exit syscall的时候用类似的办法去获取新线程的tid信息。 通过这个办法可以感知到tracee创建了新线程,这是一个办法,但是这个办法 `syscall.PtraceSyscall(pid, signal)` 过于通用了,你还要懂点ABI调用惯例(比如通过寄存器分配来传递系统调用号、返回值信息),使用起来就没有那么方便。 **方法2**:通过 `syscall.PtraceSetOptions(pid, opts)` 指定 `PTRACE_O_TRACECLONE` 让内核自动跟踪clone 执行这个操作,需要先attach tracee之后,比如attach运行中的进程执行 `syscall.PtraceAttach(pid)` ,或者 `exec.Cmd.Ptrace=true` 指定execve时执行PTRACE_TRACEME操作。在这之后,就可以显示通过 `syscall.PtraceSetOptions(pid, opts)` 传递选项 `PTRACE_O_TRACECLONE` ,这个操作是专门为跟踪clone系统调用而设置的。 对于跟踪新线程、新进程创建而言,第二种方法更聚焦、更有针对性,容易理解和维护,设计实现时我们将采用第二种方法。在调试时跟踪任意系统调用时,就需要使用第一种方法了,后面扩展阅读部分,我们也会单独一节对此进行进一步的介绍。 #### 接收内核通知的clone调用事件 在执行完上述设置之后,tracee在执行clone操作时,tracer便会收到通知。 1. tracee执行clone系统调用时,内核会给tracee发送一个SIGTRAP信号,内核会暂停tracee执行,并通知tracer。 2. tracer需要主动去感知这个事件的发生,有两个办法: **方法1**: 通过SIGCHLD信号去感知这个事件的发生 内核会发送信号SIGCHLD给tracer,并通过si_status==SIGTRAP来说明是调试引起的。想进一步获取是因为哪个系统调用导致的,则可以通过 `syscall.PtraceGetRegs` 来从寄存器中获取系统调用编号,通过与clone系统调用编号比较即可判断当前tracee是否执行的是clone操作。si_pid字段则包含了新创建的线程pid,也就是新线程的tid。 ```c siginfo_t { int si_signo; /* Signal number */ int si_errno; /* An errno value */ int si_code; /* Signal code */ int si_trapno; /* Trap number that caused hardware-generated signal (unused on most architectures) */ pid_t si_pid; /* Sending process ID */ uid_t si_uid; /* Real user ID of sending process */ int si_status; /* Exit value or signal */ ... } SIGCHLD fills in si_pid, si_uid, si_status, si_utime, and si_stime, providing information about the child. The si_pid field is the process ID of the child; si_uid is the child's real user ID. The si_status field contains the exit status of the child (if si_code is CLD_EXITED), or the signal number that caused the process to change state. ``` **方法2**:通过waitpid()去感知这个事件的发生 通过waitpid()是更常用的感知tracee的运行状态发生了改变的方法,执行clone系统调用的线程完成该操作后会暂停,waitpid会在status字段中记录发生了PTRACE_EVENT_CLONE事件,这样tracer就可以判断出是tracee执行clone系统调用导致的。然后就可以借助 `newpid, syscall.PtraceGetEventMsg(pid)`来获取新线程的pid信息。 see: `man 2 ptrace` 中关于选项 PTRACE_O_TRACECLONE 的说明 ```bash PTRACE_O_TRACECLONE (since Linux 2.5.46) Stop the tracee at the next clone(2) and automatically start tracing the newly cloned process, which will start with a SIGSTOP, or PTRACE_EVENT_STOP if PTRACE_SEIZE was used. A waitpid(2) by the tracer will return a status value such that status>>8 == (SIGTRAP | (PTRACE_EVENT_CLONE<<8)) The PID of the new process can be retrieved with PTRACE_GETEVENTMSG. This option may not catch clone(2) calls in all cases. If the tracee calls clone(2) with the CLONE_VFORK flag, PTRACE_EVENT_VFORK will be delivered instead if PTRACE_O_TRACEVFORK is set; otherwise if the tracee calls clone(2) with the exit signal set to SIGCHLD, PTRACE_EVENT_FORK will be delivered if PTRACE_O_TRACEFORK is set. ``` tracer如果确定了是clone导致的以后,可以进一步通过 `newpid, _ = syscall.PtraceGetEventMsg(pid)` 拿到新线程的pid信息。 3、拿到线程pid之后就可以将新线程纳入跟踪,我们可以选择放行新线程,或者暂停新线程、读写数据、观察并控制执行。 #### 关于syscall.PtraceGetEventMsg的说明 如果我们使用ptrace的PTRACE_GETEVENTMSG操作来获取新创建线程的tid,应该注意些什么呢? - 这个event会存多久, - 这个event什么时候会被清空, - 当检测到一个新线程创建时需要理解执行该操作吗? - 可以wait到N(N>1)个线程创建后,再执行该操作吗? 这几个问题促使我们思考event的生成、存储、清空机制,这里我们进行了一个简单的总结: | 触发情况 | 消息是否被清除 | 说明 | | -------------------------------- | -------------- | ------------------------------ | | `PTRACE_CONT` 或 `PTRACE_DETACH` | **是** | 子进程继续执行,内核清空缓冲区 | | 再次出现 `PTRACE_EVENT` | **是** | 新事件写入时覆盖旧消息 | | 进程退出 | **是** | 进程结束,内核销毁结构 | | `PTRACE_GETEVENTMSG` | **否** | 只读取,不清空 | > **最佳实践**: > > 1. **在每次 `waitpid()` 返回 `SIGTRAP|0x80`(即 ptrace 事件)后立即读取事件消息**。 > 2. **随后立即发 `PTRACE_CONT`**,完成一轮“事件‑读取‑继续”循环。 > 3. 这样既能拿到所有事件消息,又能避免消息被后续事件或 `CONT` 覆盖。 这样设计,ptrace 调试过程才能顺利、准确地捕获所有 `PTRACE_EVENT` 的信息 #### 关于线程tid、pid的说明 当我们提tid的时候,其实是想说线程ID,当提pid的时候是想提线程所属进程的pid。但是有些系统调用似乎却不是这样的惯例,比如ptrace系统调用 `ptrace(pid, ...)` 尽管它的操作对象是线程,但是却用了pid这样的命名,为什么呢?这要从内核设计实现来说起。 在 Linux 内核里,**所有的可调度实体都是 `task_struct`**。“进程”并不是一种独立的结构,而是 **一组共享相同内存(`mm_struct`)的线程** 的集合——这组线程被称为 **线程组(thread group)**。 - **线程组的首个成员(线程组首领)** 的 `pid` 与 `tgid` 相等,我们习惯叫法是 “主线程"; - **其它成员**(即主线程以外的“线程”)的 `pid` 与 `tgid` 不相等,它们的 `tgid` 与组首领的 `pid` 相同。 不管是进程还是线程,它们都由各自的task_struct来表示,它们共享的内存区域则由task_struct->mm_struct来表示,其他共享的信息则通过task_struct->thread_group来描述。因为线程是通过clone时指定一些特殊的共享选项来创建出来的,task_struct中的很多信息共享自主线程,是比较轻量的,所以也经常称之为LWP(轻量级进程,Light Weight Process)。`/proc/processID/task/threadID`,threadID其实就是每个线程对应的task_struct->pid,而进程processID就是线程对应的task_struct->tgid。 系统调用里面有些函数参数定义为pid,这种一般指的是各个线程的task_struct->pid这个概念,比如ptrace系统调用。但是也有些系统调用或者库函数命名上容易让人产生歧义: - getpid,获取调用方所属的进程的pid,也就是线程所属进程的进程pid,或者说线程的tgid; - gettid,获取线程的pid(线程对应的task_struct->pid); 有时候文中会混用pid、tid概念,请读者朋友根据语境区分我们指的是线程所属进程的pid,还是线程自身的pid。 ### 设计实现 #### 准备多线程测试程序 首先为了后面测试方便,我们先用C语言来实现一个多线程程序,程序逻辑很简单,就是每隔一段时间就创建个新线程,线程函数就是打印当前线程的pid,以及线程LWP的pid。 ```c #include #include #include #include #include pid_t gettid(void); void *threadfunc(void *arg) { printf("process: %d, thread: %u\n", getpid(), syscall(SYS_gettid)); sleep(1); } int main() { printf("process: %d, thread: %u\n", getpid(), syscall(SYS_gettid)); pthread_t tid; for (int i = 0; i < 100; i++) { if (i % 10 == 0) { int ret = pthread_create(&tid, NULL, threadfunc, NULL); if (ret != 0) { printf("pthread_create error: %d\n", ret); exit(-1); } } sleep(1); } sleep(15); } ``` 这个程序可以这样编译 `gcc -o fork fork.c -lpthread`,然后运行 `./fork` 进行测试,可以看看没有被调试跟踪的时候是个什么运行效果。 #### 调试器跟踪新线程创建 然后我们再来看调试器部分的代码逻辑,这里主要是为了演示tracer(debugger)如何对多线程程序中新创建的线程进行感知,并能自动追踪。 这部分实现代码,详见 [hitzhangjie/golang-debugger-lessons/20_trace_new_threads](https://github.com/hitzhangjie/golang-debugger-lessons/tree/master/20_trace_new_threads)。 ```go package main import ( "fmt" "os" "os/exec" "runtime" "strconv" "syscall" "time" ) var usage = `Usage: go run main.go args: - pid: specify the pid of process to attach ` func main() { runtime.LockOSThread() if len(os.Args) != 2 { fmt.Println(usage) os.Exit(1) } // pid pid, err := strconv.Atoi(os.Args[1]) if err != nil { panic(err) } if !checkPid(int(pid)) { fmt.Fprintf(os.Stderr, "process %d not existed\n\n", pid) os.Exit(1) } // step1: supposing running dlv attach here fmt.Fprintf(os.Stdout, "===step1===: supposing running `dlv attach pid` here\n") // attach err = syscall.PtraceAttach(int(pid)) if err != nil { fmt.Fprintf(os.Stderr, "process %d attach error: %v\n\n", pid, err) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d attach succ\n\n", pid) // check target process stopped or not var status syscall.WaitStatus var rusage syscall.Rusage _, err = syscall.Wait4(int(pid), &status, 0, &rusage) if err != nil { fmt.Fprintf(os.Stderr, "process %d wait error: %v\n\n", pid, err) os.Exit(1) } if !status.Stopped() { fmt.Fprintf(os.Stderr, "process %d not stopped\n\n", pid) os.Exit(1) } fmt.Fprintf(os.Stdout, "process %d stopped\n\n", pid) regs := syscall.PtraceRegs{} if err := syscall.PtraceGetRegs(int(pid), ®s); err != nil { fmt.Fprintf(os.Stderr, "get regs fail: %v\n", err) os.Exit(1) } fmt.Fprintf(os.Stdout, "tracee stopped at %0x\n", regs.PC()) // step2: setup to trace all new threads creation events time.Sleep(time.Second * 2) //opts := syscall.PTRACE_O_TRACEFORK | syscall.PTRACE_O_TRACEVFORK | syscall.PTRACE_O_TRACECLONE opts := syscall.PTRACE_O_TRACECLONE if err := syscall.PtraceSetOptions(int(pid), opts); err != nil { fmt.Fprintf(os.Stderr, "set options fail: %v\n", err) os.Exit(1) } for { // 放行主线程,因为每次主线程都会因为命中clone就停下来 if err := syscall.PtraceCont(int(pid), 0); err != nil { fmt.Fprintf(os.Stderr, "cont fail: %v\n", err) os.Exit(1) } // 检查主线程状态,检查如果status是clone事件,则继续获取clone出的线程的lwp pid var status syscall.WaitStatus rusage := syscall.Rusage{} _, err := syscall.Wait4(pid, &status, syscall.WSTOPPED|syscall.WCLONE, &rusage) if err != nil { fmt.Fprintf(os.Stderr, "wait4 fail: %v\n", err) break } // 检查下状态信息是否是clone事件 (see `man 2 ptrace` 关于选项PTRACE_O_TRACECLONE的说明部分) isclone := status>>8 == (syscall.WaitStatus(syscall.SIGTRAP) | syscall.WaitStatus(syscall.PTRACE_EVENT_CLONE<<8)) fmt.Fprintf(os.Stdout, "tracee stopped, tracee pid:%d, status: %s, trapcause is clone: %v\n", pid, status.StopSignal().String(), isclone) // 获取子线程对应的LWP的pid msg, err := syscall.PtraceGetEventMsg(int(pid)) if err != nil { fmt.Fprintf(os.Stderr, "get event msg fail: %v\n", err) break } fmt.Fprintf(os.Stdout, "eventmsg: new thread lwp pid: %d\n", msg) // 放行子线程继续执行 _ = syscall.PtraceDetach(int(msg)) time.Sleep(time.Second * 2) } } // checkPid check whether pid is valid process's id // // On Unix systems, os.FindProcess always succeeds and returns a Process for // the given pid, regardless of whether the process exists. func checkPid(pid int) bool { out, err := exec.Command("kill", "-s", "0", strconv.Itoa(pid)).CombinedOutput() if err != nil { panic(err) } // output error message, means pid is invalid if string(out) != "" { return false } return true } ``` ### 代码测试 1、先看看testdata/fork.c,这个程序每隔一段时间就创建一个pthread线程出来 主线程、其他线程创建出来后都会打印该线程对应的pid、tid(这里的tid就是对应的lwp的pid) ```bash zhangjie🦀 testdata(master) $ ./fork process: 35573, thread: 35573 process: 35573, thread: 35574 process: 35573, thread: 35716 process: 35573, thread: 35853 process: 35573, thread: 35944 process: 35573, thread: 36086 process: 35573, thread: 36192 process: 35573, thread: 36295 process: 35573, thread: 36398 ... ``` 2、我们同时观察 `./20_trace_new_threads $(pidof fork)` 的执行情况 ```bash zhangjie🦀 20_trace_new_threads(master) $ ./20_trace_new_threads 35573 ===step1===: supposing running `dlv attach pid` here process 35573 attach succ process 35573 stopped tracee stopped at 7f318346f098 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 35716 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 35853 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 35944 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 35944 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 35944 tracee stopped, tracee pid:35573, status: trace/breakpoint trap1, trapcause is clone: true eventmsg: new thread lwp pid: 36086 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 36192 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 36295 tracee stopped, tracee pid:35573, status: trace/breakpoint trap, trapcause is clone: true eventmsg: new thread lwp pid: 36398 .. ``` 3、20_trace_new_threads 每隔一段时间都会打印一个event msg: `` 结论就是,我们通过显示设置PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE)后,恢复tracee执行,这样tracee执行起来后,当执行到clone系统调用时,就会触发一个TRAP,内核会给tracer发送一个SIGTRAP来通知tracee运行状态变化。然后tracer就可以检查对应的status数据,来判断是否是对应的clone事件。 如果是clone事件,我们可以继续通过syscall.PtraceGetEventMsg(...)来获取新clone出来的线程的LWP的pid。 检查是不是clone事件呢,参考 `man 2 ptrace` 手册对选项PTRACE_O_TRACECLONE的介绍部分,有解释clone状况下的status值如何编码。 4、另外设置了选项PTRACE_O_TRACECLONE之后,新线程会自动被trace,所以新线程也会被暂停执行,此时如果希望新线程恢复执行,我们需要显示将其syscall.PtraceDetach或者执行syscall.PtraceContinue操作来让新线程恢复执行。 ### 思考:线程上下文切换特性支持 #### Go语言GMP调度的特殊性 不同于C\C++多线程编程操作时面向线程的,线程函数即业务逻辑,而在go语言中,并不是这样。对于go语言而言,线程有着特殊的意义。go提供的是面向协程goroutine级别的并发,比如chan sendrecv、mutex加解锁等等。由于GMP调度设计的原因,实际上我们也很难知道某个特定的goroutine会在哪个thread上执行,同一个goroutine的完整代码逻辑实际上也不一定会固定在同一个thread上执行 …… 调试go程序时,我们可能极少有诉求去跟踪某个特定的线程的执行情况。 #### 什么时候需要在线程间切换 dlv实际上是提供了 `threads` 和 `thread` 调试命令,来允许调试人员查看当前存在的线程以及在它们之间进行切换。那什么调试情景下我需要用到这个线程切换能力呢? 现在主流调试器对于进程内线程管理,基本上都是采用Stop-all、Start-all Mode(原因就是方便观察、避免线程间同步逻辑异常),所以当我们提到线程间切换的时候,其实指的是将当前调试器命令执行时的上下文(context),切换为目标线程的上下文(context),比如执行命令pregs时就是打了当前线程的硬件上下文信息,而不是其他被跟踪的线程的硬件上下文信息。 如果调试的线程执行的是cgo代码(这部分代码逻辑不会像goroutine逻辑那样会在线程间迁移),如果调试的goroutine执行了runtime.LockOSThread(),如果需要查看go运行时的底层逻辑,比如GMP调度,或者需要调试看下不同线程的线程可见性问题 …… 确实还是会有些场景需要用到线程上下文切换能力的支持。 ### 思考:需要自动切换到新线程吗 在调试多进程程序时,当执行 `fork` 创建子进程后,调试人员希望能选择跟踪父进程还是子进程。 举个例子,比如我们要跟踪 `protoc` 编译器的插件实现 `protoc-gen-go`,protocolbuffers编译器protoc及其非内置支持语言的工具支持是通过插件机制来完成的,protoc编译器负责读取并解析 `*.proto` 文件,并生成一个代码生成请求发送给插件,方式就是在 `$PATH` 中搜索 `protoc-gen-go` 并启动它,然后通过stdin, stdout来传递请求并获取结果。如果你对此感兴趣,可以阅读 [Protoc及其插件工作原理](https://www.hitzhangjie.pro/blog/2017-05-23-protoc%E5%8F%8A%E6%8F%92%E4%BB%B6%E5%B7%A5%E4%BD%9C%E5%8E%9F%E7%90%86%E5%88%86%E6%9E%90%E7%B2%BE%E5%8D%8E%E7%89%88/)。 如果我们用gdb对protoc进行调试(protoc编译器是用C++写的),并且希望对插件实现protoc-gen-go进行调试,就可以通过gdb中的 `set follow-fork-mode childk` 来选择跟踪子进程。这个功能是非常方便的,你不需要担心子进程执行过快越过想调试的代码部分。如果你不知道这个调试特性,可能会在子进程初始化逻辑中设置一个forloop然后attach后再跳出forloop才能对感兴趣的代码进行调试。 OK,那对于多线程程序而言,我们跟踪到一个新线程时,是否需要允许用户选择,你需要切换到某个线程上下文去吗?意义应该不大。首先Go主要是面向goroutine级别的并发控制操作,调试时切换线程作用不大。即使前面提到确实有些场景需要线程切换支持,我们也可以手动执行命令 `thread ` 来切换,所以没有必要支持自动跟踪并切换到新线程的特性。 ### 思考:dlv是否支持多进程调试 前面提到gdb支持对子进程进行自动跟踪,其实dlv也支持类似的调试模式:`target follow-exec [-on [regex]] | [-off]` ```bash (dlv) help target Manages child process debugging. target follow-exec [-on [regex]] [-off] Enables or disables follow exec mode. When follow exec mode Delve will automatically attach to new child processes executed by the target process. An optional regular expression can be passed to 'target follow-exec', only child processes with a command line matching the regular expression will be followed. ... ``` 当通过exec.Command启动一个子进程时,如果您希望跟踪子进程,则可以通过上述 `target follow-exec` 操作来实现,并且还允许你通过正则的形式来对子进程名进行匹配检查,匹配则自动跟踪子进程。 ### 本节小结 本节主要探讨了调试器如何感知和跟踪新创建线程的技术实现,核心内容包括:通过 `PTRACE_O_TRACECLONE` 选项让内核自动跟踪clone系统调用;利用 `waitpid()` 和 `PTRACE_EVENT_CLONE` 事件机制感知新线程创建;通过 `PtraceGetEventMsg()` 获取新线程的LWP ID并将其纳入调试器管理。本节还深入分析了Linux内核中线程与进程的本质区别,以及Go语言GMP调度模型对线程调试的特殊影响,为读者理解多线程、多进程调试的底层机制提供了重要基础。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/3-suspend_policy.md ================================================ ## 线程执行控制 - 挂起策略 线程执行控制,指的是调试器会通过continue、step、breakpoint等命令来控制进程内线程的执行,前面我们介绍了这些命令的底层原理以及实现方式。但是这些对于多线程程序来说,还远远不够,我们还需要考虑多线程程序的特殊性,线程与线程之间的交互、调试人员对不同线程的观察等等,这就涉及到线程的挂起策略。 挂起策略(Suspend Policy),有时也叫 **Stop Mode** 或者 **Suspend Mode**。它描述的是当某个线程触发断点/异常/停止请求时,调试器应该暂停哪些线程,以及后续继续执行时,如何恢复这些线程的执行,属于线程执行控制(Thread Execution Control)的一部分。 本节我们来了解下在主流调试器中,有哪些挂起策略,以及是如何实现的。 ## 多线程调试 - 挂起策略及主流实现 ### 调试器的挂起策略 在多线程或多进程的程序调试中,当程序执行遇到断点(breakpoint)、观察点(watchpoint)、捕获到信号(signal)或被用户手动中断时(如ctrl+c),调试器需要暂停(Suspend)程序的执行。**挂起策略(Suspend Policy)** 定义了当程序中的某个线程被暂停时,其他线程或进程应该如何处理的行为规则。是只暂停当前发生事件的线程,还是暂停整个进程中的所有线程。而当恢复执行(continue)、步进(step, next等)时,是只恢复当前选中的线程,还是进程内的所有线程。这是两个维度,一个是暂停的维度,一个是恢复的维度。 前面提的是单进程多线程调试的范畴,我们还可以延伸到多进程程序调试的范畴,比如调试器在调试进程P1时,发现进程P1创建了子进程P2,后续命中断点、观察点、捕获到信号、收到用户中断操作时,我们希望有能力按需地对单个进程或者所有进程暂停执行的能力,以及在后续收到continue、step、next等恢复执行的命令时也能按需地控制恢复单个进程内的线程或者所有进程内的线程的执行能力。 这个策略至关重要,因为它直接影响了调试过程中的程序状态可见性、可预测性和用户调试体验。 主流的挂起策略主要有两种: 1. **全系统挂起 (All-Stop)**: 当一个线程停止时,调试器会尝试暂停整个被调试程序中的所有线程。 2. **单线程挂起 (Non-Stop / One-Stop)**: 当一个线程停止时,只有该线程会被暂停,其他线程继续运行。 ### 主流调试器的挂起策略 #### GDB (GNU Debugger) **GDB** 传统上采用的是**全系统挂起 (All-Stop)** 策略。 * **默认行为:** 当 GDB 调试的程序中任何一个线程命中断点或接收到信号时,整个进程(包括所有线程)都会被暂停。 * **优点:** 状态是“冻结”的,这使得检查全局变量、内存状态和线程间的交互更加容易和稳定,避免了其他线程在检查时修改数据,从而简化了调试的复杂性。 * **缺点:** 在调试高度并发或有严格时序要求的程序时,暂停所有线程可能会改变程序的实时行为,甚至导致死锁或其他与“暂停”相关的非真实错误。 虽然 GDB 默认是全系统挂起,但它也提供了控制单个或部分线程执行的能力(例如使用 `thread apply all ` ,如暂停后对特定线程使用 `continue` / `step` / `next`)。 OK,这里不妨展开介绍下GDB中与挂起策略相关的几个选项设置。 **设置1:set non-stop on/off (默认值off)**:本文主要的探讨的挂起策略,同一个进程内的所有线程,是否所有线程要停全停,要恢复全恢复。 - off:也就是all-stop mode,如果有断点、watchpoint、收到信号、用户中断等操作,暂停进程内所有线程。当执行continue,next,stop等操作时恢所有线程执行。 - on:此时就是non-stop mode,命中断点、watchpoint、收到信号、用户中断操作,仅暂停发生事件的线程。当执行continue,next,step操作时仅恢复当前选中线程。 **设置2:set schedule_multiple on/off (默认值off)**:如果是多进程程序,是否恢复所有进程内的线程的执行。 - off: 当执行continue,next,step操作时只恢复当前进程内的线程执行; - on: 当执行continue,next,step操作时恢复所有进程内的线程执行; 这里可以被恢复的线程集合,进一步可以由`scheduler_locking`选项来控制。 **设置3:set scheduler_locking on/off/step/replay (默认值off)**:决定在程序运行、单步或继续时,GDB 是否会把其他线程“锁定”(即暂停),只让当前选中的线程执行。这对定位线程间交互、排查竞争、实现确定性重放都非常重要。 - on: **始终锁定**:所有线程都被暂停,只有当前选中的线程在运行,相当于永远是单线程执行 - off: **默认行为**:不锁定,所有线程按正常调度器运行。 - step: **仅在单步(step/next 等)时锁定**:当前线程执行一步后,暂停其它线程。 - replay: **为确定性重放做准备**:GDB 记录线程调度,并在 replay 时严格复现。 **为什么需要 scheduler_locking?** non-stop、schedule_multiple都很好理解,我们介绍下scheduler_locking。默认情况下,GDB 只会把你暂停的线程停下来,其他线程仍然按操作系统调度器的规则继续执行。 这会导致: - 单步调试时可能会切到别的线程,导致你走了你不想走的代码路径。 - 设置断点时,任何线程都可能触发,导致“偶发性”调试流程中断。 - 有些竞态条件在你“锁定”调试时可能根本不会出现。 `scheduler-locking` 给我们一个可控的调试模式,让我们决定 **是否需要把调度器锁定**,从而让调试行为更可预测。GDB record/replay特性是不依赖Mozilla rr的,但是由于是指令级记录,性能开销比rr大。GDB也可以通过gdb serial协议访问rr来进行录制重放。 #### LLDB **LLDB** 旨在提供更现代和灵活的调试体验,其行为与 GDB 类似,也默认采用**全系统挂起 (All-Stop)** 策略。 * **默认行为:** 类似于 GDB,当程序因断点或其他事件停止时,**整个进程**会被暂停。 * **灵活性:** 尽管默认是全系统挂起,但 LLDB 提供了更细致的线程控制。例如,在进程停止后,理论上可以通过 LLDB 的 API(如 Python 脚本)来控制哪些线程继续运行,哪些保持暂停(如 `thread continue `,尽管命令行中不常直接使用,但其核心功能在于对线程状态的精细管理)。这种能力在某些场景下可以模拟出“部分挂起”的效果。 简而言之,对于 GDB 和 LLDB 而言,为了维护调试时状态的一致性和简便性,**默认和主流的挂起策略都是全系统挂起**。 #### Delve **Delve** 是专门为 Go 语言设计的调试器,它考虑了 Go 语言独特的并发模型(Goroutine)。 * **基于进程挂起:** Delve 在底层操作上,与传统的调试器一样,当进程被调试中断(如命中硬断点)时,**整个进程**会被操作系统挂起。这是操作系统的限制,也是所有调试器在进行底层操作时必须面对的。 * **Goroutine 抽象与全系统挂起:** 由于 Go 的并发基于 **Goroutine**(轻量级用户态线程),而非重量级操作系统线程,Delve 必须在 Go 运行时(Runtime)层面进行协调。当一个 Goroutine 命中断点时,Delve 会暂停整个 Go 程序(即进程),从而暂停所有 Goroutine 的执行。 * **关注 Goroutine:** Delve 的核心在于对 Goroutine 的抽象和管理。当程序停止时,你可以检查任何 Goroutine 的堆栈、变量状态,并且可以切换到不同的 Goroutine 上下文进行操作。 * **跟踪行为:** Delve 在附着(Attach)到一个正在运行的 Go 进程时,通常会**立即暂停**该进程,以便设置断点或进行初始化检查。但 Delve 社区曾讨论并实现了 `--continue` 或类似选项,允许在附着后立即恢复执行,以避免长时间暂停生产环境服务,使其更适合设置**跟踪点(Tracepoint)** 而非硬断点。 因此,**Delve 的挂起策略本质上是全进程挂起**,但它通过对 Go 运行时和 Goroutine 的深度感知,提供了面向 Goroutine 的调试体验。但是Delve并没有像GDB scheduler_locking那样对线程调度进行干预那样对goroutine调度进行干预,它就是所有线程、协程全停全起的一个跟踪方式,然后当命中断点、信号、用户中断等之类事件时,它会显示到底是哪个goroutine命中了哪个断点导致的这次暂停。 ```bash $ dlv debug main.go ... 3: import "time" 4: => 5: func main() { 6: for { 7: go func() { 8: println("hello") 9: println("world") 10: }() **(dlv) b 8** Breakpoint 2 set at 0x100df9ac8 for main.main.func1() ./main.go:8 **(dlv) b 9** Breakpoint 3 set at 0x100df9ae0 for main.main.func1() ./main.go:9 **(dlv) b 11** Breakpoint 4 set at 0x100df9a8c for main.main() ./main.go:11 **(dlv) c** > [Breakpoint 4] main.main() ./main.go:11 **(hits goroutine(1)**:1 total:1) (PC: 0x100df9a8c) 6: for { 7: go func() { 8: println("hello") 9: println("world") 10: }() => 11: time.Sleep(time.Second) 12: } 13: } **(dlv) c** > [Breakpoint 2] main.main.func1() ./main.go:8 **(hits goroutine(2)**:1 total:1) (PC: 0x100df9ac8) 3: import "time" 4: 5: func main() { 6: for { 7: go func() { => 8: println("hello") 9: println("world") 10: }() 11: time.Sleep(time.Second) 12: } 13: } **(dlv) c** hello > [Breakpoint 3] main.main.func1() ./main.go:9 **(hits goroutine(2)**:1 total:1) (PC: 0x100df9ae0) > [Breakpoint 4] main.main() ./main.go:11 **(hits goroutine(1)**:2 total:2) (PC: 0x100df9a8c) 6: for { 7: go func() { 8: println("hello") 9: println("world") 10: }() => 11: time.Sleep(time.Second) 12: } 13: } **(dlv) c** world > [Breakpoint 2] main.main.func1() ./main.go:8 **(hits goroutine(33):1 total:2) (PC: 0x100df9ac8) 3: import "time" 4: 5: func main() { 6: for { 7: go func() { => 8: println("hello") 9: println("world") 10: }() 11: time.Sleep(time.Second) 12: } 13: } ``` 从上面调试示例可以看出,continue后命中断点的goroutine会显示器goroutine编号,以及命中的断点位置,当前PC值。指的关注的是,有时候continue后有1个goroutine命中了断点导致所有goroutine全停下来,有时候则有不止1个goroutine命中断点。但实际上Delve在处理1个线程的断点命中事件时,当收到任意1个线程的断点命中事件后,会立即暂停所有线程的执行(通过SIGSTOP通知运行中线程停下来)。 ### 本节小结 本节主要探讨了调试器在多线程程序中的挂起策略(Suspend Policy),核心内容包括:**全系统挂起**和**单线程挂起**两种主流策略。我们还介绍了主流调试器GDB、LLDB、Delve中的做法。GDB通过默认全系统挂起,但提供了`non-stop`、`schedule_multiple`、`scheduler_locking`等选项提供细粒度控制;LLDB默认采用全系统挂起但支持更细粒度的线程管理;Delve也是默认全系统挂起,但通过其对Go语言GMP调度模型的深度理解和支持,适配了面向goroutine级别并发的调试,我们可以自由在不同goroutine上下文之间进行切换、查看状态、恢复goroutine执行,方便Go开发者进行调试。 重点需要理解的是,主流调试器都默认采用全系统挂起策略,这确保了调试时程序状态的稳定性和可预测性。尽管这种做法并不完美,大部分时候都是我们希望的,但在某些场景下我们需要更细粒度的控制,比如GDB、LLDB那样。调试器的发展演进也需要时间,我们期待后续的调试器能够提供更细粒度的控制,更方便我们进行调试。 本节内容为后续学习多线程调试的复杂性以及后续学习线程状态管理、断点处理等高级调试技术奠定了重要基础。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/4-continue.md ================================================ ## 线程执行控制 - continue ### 实现目标:多线程环境下的continue命令 前面我们已经介绍了如何跟踪进程中的已有线程,以及后续执行期间会新创建的线程。在对进程内所有已有、未来创建的线程获得了可以全部跟踪的能力之后,我们又介绍了主流调试器的线程挂起策略,如GDB、LLDB、Delve。本节我们将更进一步,介绍 All-stop Mode 下 continue 命令应该如何实现。因为主流调试器GDB、LLDB默认都是All-stop Mode,而且Delve这么多年了也支持All-stop Mode,说明这才是大多数情况下的调试诉求。 OK,现在我们开始介绍 All-stop Mode 下 continue 命令的实现,我们需要特别注意以下两种情景。 **情景1**:有的线程是因为命中断点停止,这类情况恢复逻辑稍微复杂点,大致的处理步骤如下: 1. 恢复这些线程时需要恢复断点处patched之前的指令 2. 然后PC-- 3. 然后SINGLESTEP执行到patched之前的指令 4. 然后重设断点 5. 最后PTRACE_CONT恢复执行; **情景2**:有的线程是因为收到信号SIGSTOP停止(如因其他线程命中断点,All-stop Mode下会通过SIGSTOP通知所有线程暂停),通过PTRACE_CONT恢复执行; OK,其实检查下线程当前PC-1处是不是0xCC,并且在PC-1处是一个用户添加的断点,如果是就按照情景1进行处理,否则就按照情景2进行处理。进程层面维护好所有内部包含的线程,遍历线程列表分别进行上述处理即可。 ### 设计实现 这部分代码,您可以参考 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg/blob/9f6daf9831eaaa2b5eafc5bb2ff361bddfaf7098/cmd/debug/continue.go) 中的continue命令实现。 ```go package debug import ( "fmt" "os" "github.com/hitzhangjie/godbg/pkg/target" "github.com/spf13/cobra" ) var continueCmd = &cobra.Command{ Use: "continue", Short: "运行到下个断点", Annotations: map[string]string{ cmdGroupAnnotation: cmdGroupCtrlFlow, }, Aliases: []string{"c"}, RunE: func(cmd *cobra.Command, args []string) (err error) { dbp := target.DBPProcess // 获取当前停在断点处的线程 bpStoppedThreads, err := dbp.ThreadStoppedAtBreakpoint() if err != nil { return fmt.Errorf("check thread breakpoints error: %v", err) } // 如果没有线程停在断点处,直接继续执行即可 if len(bpStoppedThreads) == 0 { return dbp.Continue() } // 有线程停在断点处,恢复断点,rewind线程pc,singlestep后恢复断点 bpCleared := make(map[uintptr]struct{}) for tid, bpAddr := range bpStoppedThreads { fmt.Printf("Thread %d stopped at breakpoint %#x\n", tid, bpAddr) // - rewind线程pc regs, err := dbp.ReadRegister(tid) if err != nil { return fmt.Errorf("read register for thread %d: %v", tid, err) } regs.SetPC(regs.PC() - 1) if err = dbp.WriteRegister(tid, regs); err != nil { return fmt.Errorf("write register for thread %d: %v", tid, err) } // - 还原指令数据 if _, cleared := bpCleared[bpAddr]; !cleared { _, err := dbp.RestoreInstruction(bpAddr) if err != nil && err != target.ErrBreakpointNotExisted { return fmt.Errorf("clear breakpoint at %#x error: %v", bpAddr, err) } bpCleared[bpAddr] = struct{}{} } // - singlestep后,要恢复断点 _, err = dbp.SingleStep(tid) if err != nil { return fmt.Errorf("single step for thread %d: %v", tid, err) } // - 重设断点 if _, err := dbp.AddBreakpoint(bpAddr); err != nil { fmt.Fprintf(os.Stderr, "warning: failed to restore breakpoint at %#x: %v\n", bpAddr, err) } else { fmt.Printf("restored breakpoint at %#x\n", bpAddr) } } // 注意,这里是恢复所有tracee执行 if err = dbp.Continue(); err != nil { return fmt.Errorf("continue error: %v", err) } fmt.Println("continue ok") return nil }, } func init() { debugRootCmd.AddCommand(continueCmd) } ``` 上面逻辑比较清晰,首先找到所有因为断点暂停的线程列表,然后尝试恢复断点处指令,PC--,SINGLESTEP,重设断点,最后恢复所有tracee执行。接下来重点看下这里dbp.Continue()的实现。 ```go func (p *DebuggedProcess) Continue() error { // continue each thread for _, thread := range p.Threads { err := p.ExecPtrace(func() error { err := syscall.PtraceCont(thread.Tid, 0) if err == syscall.ESRCH { fmt.Fprintf(os.Stderr, "warn: thread %d exited\n", thread.Tid) return nil } return err }) if err != nil { return fmt.Errorf("ptrace cont thread %d err: %v", thread.Tid, err) } fmt.Printf("thread %d continued succ\n", thread.Tid) } // wait any thread stopped wpid, status, err := p.wait(p.Process.Pid, syscall.WSTOPPED) if err != nil { return fmt.Errorf("wait error: %v", err) } fmt.Printf("thread %d status: %v\n", wpid, descStatus(status)) fmt.Printf("stop all threads now\n") // if any thread stopped, then stop all threads again for _, thread := range p.Threads { if thread.Tid == wpid { continue } // 这里我们使用的是SINGLESTEP让线程执行一条指令后停下来,其实可以使用SIGSTOP代替, // delve中使用的是SIGSTOP的方式, see: `syscall.Tgkill(tgid, tid, syscall.SIGSTOP)`. // // 实际上ptrace singlestep的方式可以让线程更加快速地停下来,tgkill发送SIGSTOP的方式和SINGLESTEP有区别: // - SINGLESTEP方式会让线程执行一条指令后停下来; // - SIGSTOP方式,如果tracee当前在执行系统调用,会在系统调用返回后暂停;如果在用户态模式,会在执行下条用户指令前暂停; err := p.ExecPtrace(func() error { return syscall.PtraceSingleStep(thread.Tid) }) if err != nil { if err == syscall.ESRCH { fmt.Fprintf(os.Stderr, "warn: thread %d exited\n", thread.Tid) continue } fmt.Fprintf(os.Stderr, "ptrace stop thread %d err: %v", thread.Tid, err) } else { fmt.Printf("thread %d stopped succ\n", thread.Tid) } go func() { _, status, err := p.wait(thread.Tid, syscall.WSTOPPED) if err != nil { fmt.Fprintf(os.Stderr, "wait error: %v", err) } fmt.Printf("thread %d status: %v\n", thread.Tid, descStatus(status)) }() } return nil } ``` 这里的逻辑也比较清晰,首先恢复所有tracee执行,然后等待任意一个线程停止,如果任意一个线程停止,则停止所有线程,停止时我们有两种方式: - `syscall.PtraceSingleStep(tid, signal)` 方式让线程执行一条指令后停下来; - `syscall.tgkill(tgid, tid, SIGSTOP)` 方式,如果tracee当前在执行系统调用,会在系统调用返回后暂停;如果在用户态模式,会在执行下条用户指令前暂停; ### 测试验证 测试demo略,您可以自己写一个golang测试程序,并使用调试器godbg进行验证。 ### 本节小结 本节主要探讨了多线程环境下continue命令的实现机制,核心内容包括:区分两种线程停止情景(断点停止和信号停止)并采用不同的恢复策略;通过PC回退、指令恢复、单步执行、断点重设、恢复执行的完整流程处理断点停止的线程;使用PTRACE_CONT恢复所有线程执行。并通过wait、All-stop Mode实现命中断点、收到信号后的暂停逻辑。暂停线程时我们也提及了两种可能的方式及其区别(ptrace SINGLESTEP和tgkill SIGSTOP)。 本节内容为读者理解调试器在多线程环境下的执行控制机制提供了重要的实践指导,为后续学习更复杂的调试功能奠定了基础。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/5-breakpoint.md ================================================ ## 线程执行控制 - breakpoint ### 实现目标:多线程环境下的断点命中处理 前面我们已经介绍了多线程调试中的挂起策略(3-suspend_policy.md)和continue命令的实现(4-continue.md),现在我们需要深入探讨多线程环境下断点命中后的线程控制机制。 在多线程调试中,当某个线程命中断点停止时,调试器面临以下关键挑战: - **线程同步问题**:如果只停止命中断点的线程,而其他线程继续执行,可能导致线程间的同步操作(如互斥锁、信号量等)无法正常工作,造成死锁或数据竞争 - **状态一致性**:需要确保所有线程在断点命中时都能及时停止,保持进程状态的一致性,便于调试人员观察和分析 - **断点恢复复杂性**:命中断点的线程需要特殊的处理流程(恢复原始指令、调整PC、单步执行等),同时确保其他线程也能正确恢复执行 - **竞态条件处理**:多个线程可能同时命中断点,需要正确处理这种并发情况 我们的目标是实现一个支持多线程的断点处理机制,能够: 1. 及时检测任意线程的断点命中事件 2. 采用Stop-All策略停止所有相关线程 3. 正确管理线程状态和断点恢复信息 4. 为后续的continue操作做好充分准备 ### 基础知识 #### 断点命中检测机制 在多线程环境中,断点命中的检测需要结合线程状态监控和信号处理机制: **软件断点的工作原理**: - 断点指令(0xCC,即int3)替换原始指令 - 当线程执行到断点位置时,触发SIGTRAP信号 - 内核暂停线程执行并通知调试器 **断点命中检测流程**: 1. 通过`waitpid()`监控所有被跟踪线程的状态变化 2. 检查线程停止原因是否为SIGTRAP信号 3. 验证PC-1位置是否为断点指令(0xCC) 4. 确认断点地址是否在调试器管理的断点列表中 #### Stop-All策略的必要性 在多线程调试中,Stop-All策略对于断点处理至关重要: **避免线程间不一致**: - 防止其他线程在断点线程停止期间继续修改共享状态 - 确保调试人员观察到的程序状态是完整和一致的 **支持线程同步操作**: - 许多多线程程序依赖线程间的协作和同步 - 如果只有断点线程停止,可能导致死锁或无限等待 **简化调试体验**: - 调试人员可以同时观察所有线程的状态 - 便于分析线程间的交互和依赖关系 #### 线程状态转换 断点命中时的线程状态转换过程: ```go type ThreadState int const ( ThreadStateRunning ThreadState = iota ThreadStateStopped ThreadStateStoppedAtBreakpoint // 关键状态 ThreadStateStoppedAtSignal ThreadStateDetached ) // 断点命中时的状态转换 // Running -> StoppedAtBreakpoint (命中断点) // Running -> Stopped (被其他线程的断点事件影响) ``` #### 断点类型差异 **软件断点**: - 通过修改指令实现,适用于所有线程 - 在多线程环境下需要确保所有线程都能正确命中 - 恢复时需要特殊的单步执行处理 **硬件断点**: - 通过CPU调试寄存器实现,数量有限 - 通常用于特定线程的调试 - 不需要修改指令,恢复相对简单 ### 设计实现 #### 断点命中检测流程 ```go func (dbp *DebuggerProcess) DetectBreakpointHit() (*BreakpointEvent, error) { for { // 等待任意线程状态变化 threadID, status, err := dbp.WaitForAnyThread() if err != nil { return nil, err } // 检查是否为SIGTRAP信号 if !status.IsBreakpoint() { continue } // 获取线程寄存器信息 regs, err := dbp.GetRegisters(threadID) if err != nil { return nil, err } // 检查PC-1位置是否为断点指令 bpAddr := regs.PC() - 1 originalByte, err := dbp.ReadMemory(threadID, bpAddr, 1) if err != nil { return nil, err } // 验证是否为断点指令 if originalByte[0] != 0xCC { continue } // 确认断点是否在管理列表中 bp, exists := dbp.GetBreakpoint(bpAddr) if !exists { continue } return &BreakpointEvent{ ThreadID: threadID, Address: bpAddr, Breakpoint: bp, }, nil } } ``` #### 停止所有线程的实现 ```go func (dbp *DebuggerProcess) StopAllThreadsOnBreakpoint(bpEvent *BreakpointEvent) error { threads := dbp.GetAllTrackedThreads() // 记录断点命中的线程 bpEvent.Thread.State = ThreadStateStoppedAtBreakpoint dbp.UpdateThreadState(bpEvent.ThreadID, ThreadStateStoppedAtBreakpoint) // 停止所有其他运行中的线程 var runningThreads []int for _, thread := range threads { if thread.ID != bpEvent.ThreadID && thread.State == ThreadStateRunning { runningThreads = append(runningThreads, thread.ID) } } // 批量发送SIGSTOP信号 for _, threadID := range runningThreads { err := syscall.Kill(threadID, syscall.SIGSTOP) if err != nil { return fmt.Errorf("failed to stop thread %d: %v", threadID, err) } } // 等待所有线程停止 for _, threadID := range runningThreads { _, err := dbp.WaitForThread(threadID) if err != nil { return fmt.Errorf("failed to wait for thread %d: %v", threadID, err) } // 更新线程状态 dbp.UpdateThreadState(threadID, ThreadStateStopped) } return nil } ``` #### 线程状态同步管理 ```go type ThreadManager struct { threads map[int]*ThreadInfo mutex sync.RWMutex } type ThreadInfo struct { ID int State ThreadState LastPC uintptr Regs *syscall.PtraceRegs Breakpoint *BreakpointInfo // 如果停在断点处 } func (tm *ThreadManager) UpdateThreadState(threadID int, newState ThreadState) { tm.mutex.Lock() defer tm.mutex.Unlock() if thread, exists := tm.threads[threadID]; exists { thread.State = newState } } func (tm *ThreadManager) GetBreakpointThreads() []int { tm.mutex.RLock() defer tm.mutex.RUnlock() var bpThreads []int for id, thread := range tm.threads { if thread.State == ThreadStateStoppedAtBreakpoint { bpThreads = append(bpThreads, id) } } return bpThreads } ``` #### 断点恢复准备 ```go func (dbp *DebuggerProcess) PrepareBreakpointRecovery(bpEvent *BreakpointEvent) error { // 保存断点命中线程的完整上下文 regs, err := dbp.GetRegisters(bpEvent.ThreadID) if err != nil { return err } // 记录断点信息,为后续恢复做准备 bpEvent.Breakpoint.HitThreadID = bpEvent.ThreadID bpEvent.Breakpoint.OriginalPC = regs.PC() bpEvent.Breakpoint.OriginalByte = dbp.GetBreakpointOriginalByte(bpEvent.Address) // 标记需要特殊处理的断点线程 dbp.MarkThreadForBreakpointRecovery(bpEvent.ThreadID, bpEvent.Breakpoint) return nil } ``` ### 特殊情况处理 #### 多个线程同时命中断点 ```go func (dbp *DebuggerProcess) HandleConcurrentBreakpoints() error { // 检测所有命中断点的线程 bpThreads := dbp.GetBreakpointThreads() if len(bpThreads) > 1 { // 多个线程同时命中断点,选择第一个作为主要断点 primaryThread := bpThreads[0] // 其他线程标记为"被动停止" for i := 1; i < len(bpThreads); i++ { dbp.UpdateThreadState(bpThreads[i], ThreadStateStopped) } // 记录并发断点信息 dbp.LogConcurrentBreakpoint(bpThreads) } return nil } ``` #### 线程在系统调用中 ```go func (dbp *DebuggerProcess) HandleThreadInSyscall(threadID int) error { // 检查线程是否在系统调用中 regs, err := dbp.GetRegisters(threadID) if err != nil { return err } // 如果线程在系统调用中,需要特殊处理 if dbp.IsThreadInSyscall(regs) { // 等待系统调用完成或强制中断 err := dbp.InterruptSyscall(threadID) if err != nil { return fmt.Errorf("failed to interrupt syscall for thread %d: %v", threadID, err) } } return nil } ``` ### Go程序的特殊考虑 #### GMP调度模型下的断点处理 Go程序的断点处理需要考虑GMP调度模型的特殊性: ```go // Go程序中的断点类型 type GoBreakpointType int const ( UserCodeBreakpoint GoBreakpointType = iota // 用户代码断点 RuntimeBreakpoint // 运行时断点 SchedulerBreakpoint // 调度器断点 ) func (dbp *DebuggerProcess) HandleGoBreakpoint(bpEvent *BreakpointEvent) error { // 判断断点类型 bpType := dbp.ClassifyGoBreakpoint(bpEvent.Address) switch bpType { case UserCodeBreakpoint: // 用户代码断点,正常处理 return dbp.HandleUserBreakpoint(bpEvent) case RuntimeBreakpoint: // 运行时断点,需要特殊处理 return dbp.HandleRuntimeBreakpoint(bpEvent) case SchedulerBreakpoint: // 调度器断点,可能需要跳过 return dbp.HandleSchedulerBreakpoint(bpEvent) } return nil } ``` #### Goroutine断点vs线程断点 ```go func (dbp *DebuggerProcess) HandleGoroutineBreakpoint(bpEvent *BreakpointEvent) error { // 获取当前goroutine信息 g, err := dbp.GetCurrentGoroutine(bpEvent.ThreadID) if err != nil { return err } // 记录goroutine上下文 bpEvent.GoroutineID = g.ID bpEvent.GoroutineState = g.State // 如果goroutine被阻塞,需要特殊处理 if g.State == "blocked" { return dbp.HandleBlockedGoroutineBreakpoint(bpEvent) } return nil } ``` ### 性能优化 #### 减少停止所有线程的延迟 ```go func (dbp *DebuggerProcess) OptimizedStopAllThreads(bpEvent *BreakpointEvent) error { threads := dbp.GetAllTrackedThreads() // 使用goroutine并发停止线程 var wg sync.WaitGroup errChan := make(chan error, len(threads)) for _, thread := range threads { if thread.ID != bpEvent.ThreadID && thread.State == ThreadStateRunning { wg.Add(1) go func(tid int) { defer wg.Done() err := syscall.Kill(tid, syscall.SIGSTOP) if err != nil { errChan <- fmt.Errorf("failed to stop thread %d: %v", tid, err) } }(thread.ID) } } wg.Wait() close(errChan) // 检查错误 for err := range errChan { if err != nil { return err } } return nil } ``` #### 批量操作优化 ```go func (dbp *DebuggerProcess) BatchUpdateThreadStates(updates map[int]ThreadState) error { dbp.threadManager.mutex.Lock() defer dbp.threadManager.mutex.Unlock() // 批量更新线程状态 for threadID, newState := range updates { if thread, exists := dbp.threadManager.threads[threadID]; exists { thread.State = newState } } return nil } ``` ### 思考一下:断点命中的时序问题 在多线程环境中,断点命中的时序是一个重要考虑因素: 1. **竞态条件**:多个线程可能几乎同时命中断点,需要确保只有一个线程被识别为"主要断点线程" 2. **信号传递延迟**:SIGTRAP信号的传递可能存在延迟,需要设置合理的超时机制 3. **线程调度影响**:操作系统的线程调度可能影响断点检测的及时性 ### 思考一下:调试器性能影响 断点处理对调试器性能的影响: 1. **内存使用**:需要为每个线程维护状态信息,内存开销随线程数量线性增长 2. **CPU开销**:频繁的线程状态检查和信号处理会增加CPU使用率 3. **响应延迟**:停止所有线程的操作可能引入明显的延迟 优化策略: - 使用事件驱动的线程管理 - 实现线程池来管理调试线程 - 采用延迟加载策略减少内存占用 ### 本节小结 本节深入探讨了多线程调试中断点命中时的线程控制机制,重点阐述了三个核心技术点:通过SIGTRAP信号检测和PC-1位置验证实现断点命中检测;采用Stop-All策略确保所有线程在断点命中时及时停止;利用线程状态同步管理维护调试器与目标进程的一致性。此外,本节还分析了Go程序GMP调度模型下的断点处理特殊性,以及性能优化的重要考虑因素。这些内容为读者构建了完整的多线程断点处理知识体系,与前面章节的挂起策略和continue命令形成了有机的整体,为后续实现完整的调试器功能奠定了坚实的技术基础。 ================================================ FILE: book/6-develop-inst-debugger/20-multithread/README.md ================================================ ## 多线程调试的挑战 **前面章节介绍调试器工作原理时,用于演示调试器工作过程的测试代码,有意弱化了多线程情景带来的挑战:** - 被调试的进程往往倾向于使用单线程程序,如一个简单的单进程单线程shell命令,或者使用C程序编写的单线程程序; ps:注意shell管道连接的多个命令实际上是多进程,`cmd1 | cmd2 | cmd3` 这其实创建了3个进程,它们通过stdin、stdout进行输入输出重定向串在了一起; - 在介绍godbg attach、exec、continue、step、break、pregs、setregs等相关操作时,我们也还没有展开多线程情景下面临的问题; **调试多线程程序时,如果需要自由观察其中每个线程的执行情况,就要能够跟踪所有线程,存在如下问题需要解决:** - 挑战1:我们不想手动枚举进程中的所有已有线程,然后手动逐个attach - 挑战2:进程中在后续执行时还会创建新线程、新进程,我们不想定期轮询方式去感知线程创建、进程创建,然后再手动attach - 挑战3:多线程程序中的线程之间存在一些线程同步逻辑,如加解锁、信号量等等,只放行其中1个程序可能会导致相关任务无法正常执行 - 挑战4:多线程程序中的线程之间存在协作关系,如果某个线程命中断点停下了,但是其他相关线程还在执行,不利于观察进程整体执行情况 **这就要求我们做到下面几点,多线程调试时才能便利:** - `godbg attach `,调试器跟踪进程时,希望能帮我们处理进程包含的所有其他线程的 attach 操作,包括已经创建的、将来才会创建的; - 在此基础上,还希望执行 `continue` 等操作时能够放飞所有被跟踪且暂停执行的线程,让它们都获得执行机会以正常执行某些同步操作; - 并且在任意线程执行时命中断点后,能够让所有线程都尽快停下来,尽管它们某些并没有命中断点,以方便调试人员观察此时的执行状态。 接下来我们用几个小节的篇幅,介绍下如何实现上述基础的多线程调试跟踪能力。 ================================================ FILE: book/6-develop-inst-debugger/21-multiprocess/README.md ================================================ 父子进程,在调试器实现过程中,跟踪父子进程和跟踪进程内的线程,实现技术上差别不大。 尽管大多数调试场景中,我们更加侧重于单进程内的多线程调试部分,但是为了系统性介绍调试的方方面面,我们还是希望简单讲一下多进程调试中涉及到的一些内容. 必要时还可以实现类似 gdb `set follow-fork-mode=child/parent/ask` 的调试效果呢 之前讲过跟踪新线程,其实这里设置下这个选项,就可以实现跟踪新进程创建了 ```go opts := syscall.PTRACE_O_TRACEFORK | syscall.PTRACE_O_TRACEVFORK | syscall.PTRACE_O_TRACECLONE if err := syscall.PtraceSetOptions(int(pid), opts); err != nil { fmt.Fprintf(os.Stderr, "set options fail: %v\n", err) os.Exit(1) } ``` ================================================ FILE: book/6-develop-inst-debugger/3-process_start_attach.md ================================================ ## 启动调试:启动&跟踪进程 ### 实现目标:`godbg exec ` 启动并跟踪进程 #### 思考:如何让进程刚启动就停止? 前面小节介绍了通过 `exec.Command(prog, args...)`来启动一个进程,也介绍了通过ptrace系统调用attach一个运行中的进程。读者是否有疑问,这样启动调试的方式能满足调试要求吗? 当尝试attach一个运行中的进程时,进程正在执行的指令可能早已经越过了我们关心的位置。比如,我们想调试追踪下golang程序在执行main.main之前的初始化步骤,但是通过先启动程序再attach的方式无疑太滞后了,main.main可能早已经开始执行,甚至程序都已经执行结束了。 考虑到这,不禁要思索在“启动进程”小节的实现方式有没有问题。我们如何让进程在启动之后立即停下来等待调试呢?如果做不到这点,就很难做到高效的调试。 #### 内核:启动进程时内核做了什么? 启动一个指定的进程归根究底是fork+exec的组合: ```go cmd := exec.Command(prog, args...) cmd.Run() ``` - cmd.Run()首先通过 `fork`创建一个子进程; - 然后子进程再通过 `execve`函数加载目标程序、运行; 但是如果只是这样的话,程序会立即执行,可能根本不会给我们预留调试的机会,甚至我们都来不及attach到进程添加断点,程序就执行结束了。 我们需要在cmd对应的目标程序指令在开始执行之前就立即停下来!要做到这一点,就要依靠ptrace操作 `PTRACE_TRACEME`。 #### 内核:PTRACE_TRACEME到底做了什么? 先使用c语言写个程序来简单说明下这一过程,为什么不用go语言示例呢?因为go运行时和标准库做了太多工作,此处使用c语言示例能更用最简单的篇幅展示关键步骤。在这之后我们还要介绍内核对PTRACE_TRACEME操作和exec操作的处理流程,加深理解,让大家知其然知其所以然。OK,看下这里的示例。 ```c #include #include #include #include // see /usr/include/sys/user.sh `struct user_regs_struct` define ORIG_EAX_FIELD = 11 define ORIG_EAX_ALIGN = 8 // 8 for x86_64, 4 for x86 int main() { pid_t child; long orig_eax; child = fork(); if(child == 0) { ptrace(PTRACE_TRACEME, 0, NULL, NULL); execl("/bin/ls", "~", NULL); } else { wait(NULL); orig_eax = ptrace(PTRACE_PEEKUSER, child, (void *)(ORIG_EAX_FIELD * ORIG_EAX_ALIGN), (void *)NULL); printf("The child made a system call %ld\n", orig_eax); ptrace(PTRACE_CONT, child, NULL, NULL); } return 0; } ``` 上述示例中,首先父进程执行一次fork,fork返回值为0表示当前是子进程,子进程中执行一次 `ptrace(PTRACE_TRACEME,...)` 操作,下面是ptrace的定义,代码中省略了无关部分。当ptrace request为PTRACE_TRACEME,内核将更新当前进程 `task_struct* current` 的标记位 `current->ptrace = PT_PTRACED`。而该标记位将直接影响exec族函数的执行行为,exec族函数执行时会检查该标记位并做出相应处理。 **file: /kernel/ptrace.c** ```c // ptrace系统调用实现 SYSCALL_DEFINE4(ptrace, long, request, long, pid, unsigned long, addr, unsigned long, data) { ... if (request == PTRACE_TRACEME) { ret = ptrace_traceme(); ... goto out; } ... out: return ret; } /** * ptrace_traceme是对ptrace(PTRACE_PTRACEME,...)的一个简易包装函数, * 它执行检查并设置进程标识位PT_PTRACED. */ static int ptrace_traceme(void) { ... /* Are we already being traced? */ if (!current->ptrace) { ... if (!ret && !(current->real_parent->flags & PF_EXITING)) { current->ptrace = PT_PTRACED; ... } } ... return ret; } ``` #### 内核:PTRACE_TRACEME对execve影响? c语言库函数中,常见的exec族函数包括execl、execlp、execle、execv、execvp、execvpe,这些都是由系统调用execve实现的。 系统调用execve的代码执行路径大致包括: ```c -> sys_execve |-> do_execve |-> do_execveat_common ``` 函数do_execveat_common的代码执行路径大致如下,其作用是将当前进程的代码段、数据段 (初始化&未初始化数据) 用新加载的程序替换掉,然后执行新程序。 ```c -> retval = bprm_mm_init(bprm); |-> retval = prepare_binprm(bprm); |-> retval = copy_strings_kernel(1, &bprm->filename, bprm); |-> retval = copy_strings(bprm->envc, envp, bprm); |-> retval = exec_binprm(bprm); |-> retval = copy_strings(bprm->argc, argv, bprm); ``` 这里我们重点关注一下上述过程中 `exec_binprm(bprm)`,这里包含了执行新程序的逻辑。 **file: fs/exec.c** ```c #define PTRACE_EVENT_EXEC 4 static int exec_binprm(struct linux_binprm *bprm) { pid_t old_pid, old_vpid; int ret; /* Need to fetch pid before load_binary changes it */ old_pid = current->pid; rcu_read_lock(); old_vpid = task_pid_nr_ns(current, task_active_pid_ns(current->parent)); rcu_read_unlock(); ret = search_binary_handler(bprm); if (ret >= 0) { audit_bprm(bprm); trace_sched_process_exec(current, old_pid, bprm); ptrace_event(PTRACE_EVENT_EXEC, old_vpid); proc_exec_connector(current); } return ret; } ``` 这里 `exec_binprm(bprm)`内部调用了 `ptrace_event(PTRACE_EVENT_EXEC, message)`,后者将对进程current->ptrace状态进行检查,并执行处理。 有两种可能的处理路径: - 如果发现当前tracee上被tracer设置了标记 `current->ptrace & PT_EVENT_FLAG(event)`,此时就会执行 `ptrace_notify((event<<8)|SIGTRAP)` 来给tracee发送一个信号SIGTRAP; - 或者发现event==PTRACE_EVENT_EXEC,并且进程 `current->ptrace & (PT_TRACED|PT_SEIZED) == PT_TRACED`,内核将给进程发送一个SIGTRAP信号; **file: include/linux/ptrace.h** ```c #define PTRACE_EVENT_EXEC 4 #define PT_OPT_FLAG_SHIFT 3 #define PT_EVENT_FLAG(event) (1 << (PT_OPT_FLAG_SHIFT + (event))) // 1<<7 /** * ptrace_event - possibly stop for a ptrace event notification * @event: %PTRACE_EVENT_* value to report * @message: value for %PTRACE_GETEVENTMSG to return * * Check whether @event is enabled and, if so, report @event and @message * to the ptrace parent. * * Called without locks. */ static inline void ptrace_event(int event, unsigned long message) { if (unlikely(ptrace_event_enabled(current, event))) { current->ptrace_message = message; ptrace_notify((event << 8) | SIGTRAP); } else if (event == PTRACE_EVENT_EXEC) { /* legacy EXEC report via SIGTRAP */ if ((current->ptrace & (PT_PTRACED|PT_SEIZED)) == PT_PTRACED) send_sig(SIGTRAP, current, 0); } } /** * ptrace_event_enabled - test whether a ptrace event is enabled * @task: ptracee of interest * @event: %PTRACE_EVENT_* to test * * Test whether @event is enabled for ptracee @task. * * Returns %true if @event is enabled, %false otherwise. */ static inline bool ptrace_event_enabled(struct task_struct *task, int event) { return task->ptrace & PT_EVENT_FLAG(event); } ``` 在Linux下面,SIGTRAP信号处理函数将使得进程暂停执行,并向父进程通知自身的状态变化,然后父进程通过wait系统调用来获取子进程状态的变化信息。ptrace_event中这两个分支都可以实现对tracee通知SIGTRAP的目的。结合我们的上述示例,子进程执行 `ptrace(PTRACE_TRACEME, ...)` 操作,然后再执行execve替换掉代码段和数据段内容,最终实际上是通过第2个分支 `send_sig(SIGTRAP, current, 0)` 来发送SIGTRAP信号给tracee。 我们先展开第1个分支来看看: - 第1个分支什么时候会命中?被调试进程已经存在了,tracer先ptrace attach,然后再调用 `ptrace(PTRACE_SETOPTIONS,...)` 来设置PTRACE_EVENT_EXEC,目的是跟踪进程后续exec的行为; ps:再比如后面将提到的,可以设置选项来跟踪进程中新创建的线程,这个选项就是PTRACE_O_TRACECLONE。 - 第2个分支什么时候会命中?这是对经典ptraceme操作的处理,tracer主动启动被调试程序,先fork出子进程,然后子进程执行 `ptrace(PTRACE_TRACEME, ...)`,目的是让被调试进程启动后立即被跟踪; 我们简单看下这里的处理过程,内核在执行处理时,这两个分支有点微妙的区别。这里涉及到内核对几个关键信号SIGCHLD(通知调试器tracee已暂停)、SIGTRAP(内核发送给tracee)的使用及处理,以及对任务调度(被调试程序暂停调度)、任务唤醒的处理(唤醒调试器)。我们有必要介绍下这里的细节,让大家理解这里的一些区别。 ##### 分支1:跟踪已运行进程中后续exec行为 ```bash |-> ptrace_notify | |-> ptrace_do_notify | | |-> ptrace_stop | | | | // 设置一个不可调度状态 | | | |-> set_special_state(TASK_TRACED); | | | | // 通知tracer | | | |-> do_notify_parent_cldstop(current, true, why) | | | | kernel_siginfo, info.si_signo=SIGCHLD, info.si_code = CLD_STOPPED | | | | __group_send_sig_info(SIGCHLD, &info, parent); | | | | __wake_up_parent(tsk, parent); | | | | // 禁用抢占,不允许被调度 | | | |-> freezable_schedule() ``` ptrace_do_notify -> ptrace_stop -> do_notify_parent_cldstop(),这里的tracee通知tracer(或者父进程)我已经停下来了,会发送信号 SIGCHLD 的方式来通知tracer,但是这里的SIGCHLD不一定会生成,比如tracer实现中故意屏蔽SIGCHLD信号。所以,内核还有更保险的一个做法,`__wake_up_parent(task, parent)`,在ptrace link关系中,这里的tsk就是tracee,parent就是tracer。 ##### 分支2:被调试进程启动后立即被跟踪 分支2的情况下,要先send_sig发送信号SIGTRAP给tracee。 ```bash |-> send_sig(SIGTRAP, current, 0) |-> send_sig_info(sig, __si_special(priv), p) |-> do_send_sig_info(sig, info, p, PIDTYPE_PID) |-> send_signal(sig, info, p, type) |-> __send_signal(sig, info, t, type, force) |-> signalfd_notify(t, sig) // linux内核允许通过fd来收信号 |-> sigaddset(&pending->signal, sig) |-> complete_signal(sig, t, type); ``` 这里得先介绍一点关于信号的预备知识,Linux中信号分为同步信号和异步信号: - 同步信号是指令执行时就同步生成的,表示发生了严重事件,通常是不能耽搁处理的,也不建议捕获后自定义信号处理函数,比如SIGSEGV、SIGTRAP等,因为处理不当可能问题更大。 - 异步信号,这个是程序执行期间由外部操作生成的,如Ctrl+C生成的SIGINT、SIGTERM、SIGQUIT等,这类信号的处理等到程序执行系统调用返回用户态前处理即可,是可以捕获并自定义信号处理函数的; ps: 但是go运行时有捕获部分SIGSEGV将其转为panic进行处理。 Linux定义的同步信号主要有下面几个,其中就有我们关心的SIGTRAP: ```c #define SYNCHRONOUS_MASK \ (sigmask(SIGSEGV) | sigmask(SIGBUS) | sigmask(SIGILL) | \ sigmask(SIGTRAP) | sigmask(SIGFPE) | sigmask(SIGSYS)) ``` 信号发送实际上就是在进程task_struct的信号相关队列里进行记录,它是发送给这个进程的,而不是特定的某个线程。进程中的任何一个线程在执行系统调用返回用户态前都有机会取走接收到的信号并处理。内核中的信号处理逻辑,专门会检查当前进程有没有设置ptraced状态,有就会优先执行对应的调试相关的特殊处理逻辑,比如暂停tracee执行并通知唤醒tracer。 下面来看看进一步的细节,这个在命中断点时,处理流程也是近似的。当前tracee线程是在执行系统调用execve哦,执行期间发现有task_struct->ptraced=true标识,然后内核为其生成了一个SIGTRAP信号,当tracee线程从系统调用返回用户程序之前,会有一个契机执行一遍信号处理。其实,就是一个get_signal、handle_signal的过程。对于SIGTRAP比较特殊,它是同步信号,会被优先出队并进行处理,并且会停止当前tracee调度,并通知唤醒tracer。 ```bash arch_do_signal_or_restart(struct pt_regs *regs, bool has_signal) |-> get_signal | | // 同步信号优先出队进行处理,比如SIGTRAP | |-> signr = dequeue_synchronous_signal(&ksig->info); | | | | // 如果是ptrace SIGTRAP,那么立即去执行处理, | | // - 先暂停tracee执行, | | // - 再通知tracer | |-> if (unlikely(current->ptrace) && signr != SIGKILL) | | signr = ptrace_signal(signr, &ksig->info); | | |-> ptrace_stop(signr, CLD_TRAPPED, 0, info); | | | | // 设置一个不可调度状态 | | | |-> set_special_state(TASK_TRACED); | | | | // 通知tracer | | | |-> do_notify_parent_cldstop(current, true, why) | | | | kernel_siginfo, info.si_signo=SIGCHLD, info.si_code = CLD_STOPPED | | | | __group_send_sig_info(SIGCHLD, &info, parent); | | | | __wake_up_parent(tsk, parent); | | | | // 禁用抢占,不允许被调度 | | | |-> freezable_schedule() | |-> else 如果是其他普通信号,且sig_handler != IGN,则转handle_signal执行 | |-> handle_signal(struct ksignal *ksig, struct pt_regs *regs) | | // 设置signal handler执行需要的栈帧 | |-> failed = setup_rt_frame(ksig, oldset, regs) | | // 切换上下文,执行对应的signal handler | |-> fpu__clear_user_states(fpu) // enter signal handler | | // 执行信号处理函数结束 | |-> signal_setup_done(failed, ksig, stepping); ``` OK,大致就是这样一个流程,大家能消化的了最好,消化不了知道个大概也不影响我们继续本节内容。 ##### tracer从wait4中被唤醒 那么tracer(或者父进程)wait4 的实现,是怎么实现的呢? 我们这里也进行了一个精简版的总结: 1. 简单来说,就是tracer或者父进程将自己加入一个等待子进程状态改变的等待队列中,然后将自己设置为可中断等待状态“INTERRUPTIBLE”,意思就是可以被信号唤醒,如SIGCHLD信号。 2. 然后tracer就调用一次进程调度,让出CPU去等待了,直到tracee因为PTRACE_TRACEME停下来,给tracer发信号通知SIGCHLD or __wake_up_parent,此时tracer被唤醒。 3. tracer此时会将自己从可中断等待状态“INTERRUPTIBLE”切换为“RUNNING”状态,从等待tracee状态改变的等待队列中移除,然后等待被scheduler调度。 4. 当tracer被scheduler调度到之后,它就可以继续执行后续处理了。 最终,tracer从syscall.Wait4系统调用阻塞状态中唤醒,从wait4返回,就可以继续执行后续的其他ptrace操作了。 ```bash |-> wait4 |-> kernel_wait4 |-> do_wait ``` 下面来详细看看: ```c SYSCALL_DEFINE4(wait4, pid_t, upid, int __user *, stat_addr, int, options, struct rusage __user *, ru) { struct rusage r; long err = kernel_wait4(upid, stat_addr, options, ru ? &r : NULL); if (err > 0) { if (ru && copy_to_user(ru, &r, sizeof(struct rusage))) return -EFAULT; } return err; } long kernel_wait4(pid_t upid, int __user *stat_addr, int options, struct rusage *ru) { ... ret = do_wait(&wo); ... } static long do_wait(struct wait_opts *wo) { ... // 将当前线程加入到进程共享的等待队列中 (注意这个current->signal是进程专属字段,所有线程共享) add_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait); do { // 将当前线程设置为可中断等待状态 set_current_state(TASK_INTERRUPTIBLE); ... // 执行一轮调度,当前线程让出CPU进入等待 schedule(); } while (1); // 等到被唤醒并重新调度 __set_current_state(TASK_RUNNING); remove_wait_queue(¤t->signal->wait_chldexit, &wo->child_wait); return retval; } ``` 什么时候被唤醒呢?当tracee状态发生变化时显示通过 do_notify_parent_cldstop 通知时: ```c static void do_notify_parent_cldstop(struct task_struct *tsk, bool for_ptracer, int why) { ... // 仅考虑for_ptracer==true的情景,此时信号是发送给ptracer所属调试器进程的, // 调试器进程中不同的线程在处理信号时,它们的行为是有差异的: // - 如果是untraced线程,可以处理信号; // - 如果是traced线程,会进入signal-deliver-sigstop,暂停该线程执行,并将该线程收到的信号告知给这个线程的调试器 // 这个线程的调试器则可以通过PTRACE_RESTART操作的同时inject signal给tracee(此时可以更换成别的信号); // // 这个信号不一定总是生成,不一定能够唤醒ptracer进程上wait4阻塞调用! __group_send_sig_info(SIGCHLD, &info, parent); // 唤醒ptracer线程,之前ptracer因为wait4调用阻塞,这个函数是最靠谱的,它直接唤醒parent上等待tsk状态变化的所有线程 __wake_up_parent(tsk, parent); } void __wake_up_parent(struct task_struct *p, struct task_struct *parent) { // 唤醒ptracer所属进程上正在通过wait4等待当前tracee状态改变的所有线程 // parent->signal是进程专属字段,所有线程共享 __wake_up_sync_key(&parent->signal->wait_chldexit, TASK_INTERRUPTIBLE, p); } ``` OK,以上提了下阻塞和唤醒的内核中的交互式过程。 父进程也可通过 `ptrace(PTRACE_COND, pid, ...)` 操作来恢复子进程执行,使其继续执行execve加载的新程序。 #### Put it Together 现在,我们结合上述示例,再来回顾一下整个过程、理顺一下。 首先,父进程调用fork、子进程创建成功之后是处于就绪态的,是可以运行的。然后,子进程先执行 `ptrace(PTRACE_TRACEME, ...)`告诉内核“**当前进程希望在后续execve执行新程序时停下来,等待父进程的ptrace操作,所以请通知我在合适的时候停下来**”。子进程执行execve加载新程序,重新初始化进程执行所需要的代码段、数据段等等。 重新初始化完成之前内核会将进程状态调整为“**UnInterruptible Wait**”阻止其被调度、响应外部信号,完成之后,再将其调整为“**Interruptible Wait**”,即可以被信号唤醒,意味着如果有信号到达,则允许进程对信号进行处理。 接下来,如果该进程没有特殊的ptrace状态,子进程状态将被更新为可运行等待下次调度。当内核发现这个子进程ptrace标记位为PT_PTRACED时,则会执行这样的逻辑:内核给这个子进程发送了一个**SIGTRAP**信号,该信号将被追加到进程的pending信号队列中,当内核任务调度器调度到该进程,该进程执行系统调用返回用户态之前,发现其有pending信号到达,将执行SIGTRAP的信号处理逻辑,只不过SIGTRAP比较特殊,作为一种同步信号会在进程从内核态返回用户态时立即被处理。 **SIGTRAP信号处理具体做什么呢?**它会暂停目标进程的执行,并按条件发送SIGCHLD信号向父进程通知自己的状态变化,最后会有__wake_up_parent做兜底唤醒父进程。注意,在PTRACE_TRACEME的场景下,子进程调用 `ptrace(PTRACE_TRACEME, ...)` 后,父进程需要通过系统调用wait等待tracee停下来并获取进程状态。tracer调用wait会将tracer状态变为 "**Interruptible Wait**",当前tracer会被加入tracee进程状态变化的等待队列里。直到前面讲的内核处理tracee的SIGTRAP信号后将其停下来,然后发送SIGCHLD信号或__wake_up_parent将tracer唤醒。 ps: 发送SIGCHLD信号唤醒tracer的方式不可靠,因为tracer进程有可能屏蔽该信号,所以最后有__wake_up_parent(...)方法直接将tracer唤醒这种方式兜底。 此时,tracer被唤醒,wait就可以返回子进程tracee的当前状态。tracer发现子进程tracee已经停下来(并且是因为SIGTRAP停下来),就可以发起后续调试命令对应的ptrace操作,如读写内存数据。 ### 代码实现 src详见:golang-debugger-lessons/3_process_startattach。 类似c语言fork+exec的方式,go标准库提供了一个ForkExec函数实现,以此可以用go重写上述c语言示例。但是,go标准库提供了另一种更简洁的方式。 我们首先通过 `cmd := exec.Command(prog, args...)` 获取一个cmd对象,在 `cmd.Start()` 启动进程前打开进程标记位 `cmd.SysProcAttr.Ptrace=true`,然后再 `cmd.Start()`启动进程,最后调用 `Wait`函数来等待子进程(因为SIGTRAP)停下来并获取子进程的状态。 在这之后,父进程便可以继续做些调试相关的工作了,如读写内存等。 这里的示例代码,是在以前示例代码基础上修改得来,修改后代码如下: ```go package main import ( "fmt" "os" "os/exec" "runtime" "strconv" "strings" "syscall" "time" ) const ( usage = "Usage: ./godbg exec " cmdExec = "exec" cmdAttach = "attach" ) func main() { runtime.LockOSThread() if len(os.Args) < 3 { fmt.Fprintf(os.Stderr, "%s\n\n", usage) os.Exit(1) } cmd := os.Args[1] switch cmd { case cmdExec: args := os.Args[2:] fmt.Printf("exec %s\n", strings.Join(args, "")) if len(args) != 1 { fmt.Println("参数错误") os.Exit(1) } // start process but don't wait it finished progCmd := exec.Command(args[0]) progCmd.Stdin = os.Stdin progCmd.Stdout = os.Stdout progCmd.Stderr = os.Stderr progCmd.SysProcAttr = &syscall.SysProcAttr{ Ptrace: true, // this implies PTRACE_TRACEME } if err := progCmd.Start(); err != nil { fmt.Println(err) os.Exit(1) } // wait target process stopped var ( status syscall.WaitStatus rusage syscall.Rusage ) pid := progCmd.Process.Pid if _, err := syscall.Wait4(pid, &status, syscall.WALL, &rusage); err != nil { fmt.Println(err) os.Exit(1) } fmt.Printf("process %d stopped:%v\n", pid, status.Stopped()) case cmdAttach: // ... default: fmt.Fprintf(os.Stderr, "%s unknown cmd\n\n", cmd) os.Exit(1) } } ``` 其实这里通过设置进程启动选项ptrace=true的方式,到了标准库代码后,启动时也是类似我们c中处理的方式,先fork一个子进,然后子进程设置ptraceme,然后再execve。 ```go func forkAndExecInChild1(argv0 *byte, argv, envv []*byte, chroot, dir *byte, attr *ProcAttr, sys *SysProcAttr, pipe int) (pid uintptr, pidfd int32, err1 Errno, mapPipe [2]int, locked bool) { // set clone flags flags = sys.Cloneflags if sys.Cloneflags&CLONE_NEWUSER == 0 && sys.Unshareflags&CLONE_NEWUSER == 0 { flags |= CLONE_VFORK | CLONE_VM } ... // clone child process if clone3 != nil { pid, err1 = rawVforkSyscall(_SYS_clone3, uintptr(unsafe.Pointer(clone3)), unsafe.Sizeof(*clone3), 0) } else { pid, err1 = rawVforkSyscall(SYS_CLONE, flags, 0, uintptr(unsafe.Pointer(&pidfd))) } ... // Enable tracing if requested. // Do this right before exec so that we don't unnecessarily trace the runtime // setting up after the fork. See issue #21428. if sys.Ptrace { _, _, err1 = RawSyscall(SYS_PTRACE, uintptr(PTRACE_TRACEME), 0, 0) ... } // Time to exec. _, _, err1 = RawSyscall(SYS_EXECVE, uintptr(unsafe.Pointer(argv0)), uintptr(unsafe.Pointer(&argv[0])), uintptr(unsafe.Pointer(&envv[0]))) ... ``` ### 代码测试 下面我们针对调整后的代码进行测试: ```bash $ cd golang-debugger/lessons/0_godbg/godbg && go install -v $ $ godbg exec ls exec ls process 2479 stopped:true godbg> exit cmd go.mod go.sum LICENSE main.go syms target ``` 首先,我们进入示例代码目录编译安装godbg,然后运行 `godbg exec ls`,意图对PATH中可执行程序 `ls`进行调试。 godbg将启动ls进程,并通过PTRACE_TRACEME让内核把ls进程停下,可以看到调试器输出 `process 2479 stopped:true`,表示被调试进程pid是2479已经停止执行了。 并且还启动了一个调试回话,终端命令提示符应变成了 `godbg> `,表示调试会话正在等待用户输入调试命令,我们除了 `exit`命令还没有实现其他的调试命令,我们输入 `exit` 退出调试会话。 > NOTE:关于调试会话 > > 这里的调试会话,允许用户输入调试命令,用户所有的输入都会转交给cobra生成的debugRootCmd处理,debugRootCmd下包含了很多的subcmd,比如breakpoint、list、continue、step等调试命令。 > > 在写这篇文档时,我们还是基于cobra-prompt来管理调试会话命令及输入补全的,将上述debugRootCmd交给cobra-prompt管理后,当我们输入一些信息后,prompt就会处理我们的输入并交给debugRootCmd注册的同名命令进行处理。 > > 如我们输入了exit,则会调用debugRootCmd中注册的exitCmd进行处理。exitCmd只是执行os.Exit(0)让进程退出,在退出之前内核会自动做些清理操作,如正在被其跟踪的tracee会被内核执行ptrace(PTRACE_COND,...)解除跟踪,让tracee恢复执行。 当我们退出调试会话时,会通过 `ptrace(PTRACE_COND,...)` 操作来恢复被调试进程继续执行,也就是ls正常执行列出目录下文件的命令,我们也看到了它输出了当前目录下的文件信息 `cmd go.mod go.sum LICENSE main.go syms target`。 `godbg exec ` 命令现在一切正常了! > NOTE: 示例中程序退出时,没有显示调用 `ptrace(PTRACE_COND,...)` 来恢复tracee的执行。其实tracer退出时,如果某个目标线程还处于被跟踪状态,内核会自动解除tracee的跟踪状态,还它自由。 > > 如果tracee是我们主动启动的(不是attach的已运行中进程),那么在调试器退出时允许选择是否kill掉该进程。 再次思考下,如果我们exec执行的是一个go程序,应该如何处理呢?前1节有提到过,如果目标进程中已创建多个线程,我们可以枚举 `/proc//task` 下的线程列表逐个attach。但是对于新建的线程呢?从主线程启动到陆续创建出其他的gc、sysmon、执行众多goroutines的线程是有一个过程的,这个过程中我们如何感知有新线程创建并自动attach呢?难道要写个定时器频繁遍历 `/proc//tasks`? 我们必须精确到线程创建之初就立即跟踪它,然后根据它执行时的情况决定在后面何处设置断点,这样才更符合调试人员习惯。 这就涉及到ptrace attach的具体选项 `PTRACE_O_TRACECLONE` 了,添加了这个选项后,内核会在clone创建新线程时给新线程发送必要的信号SIGTRAP,等新线程创建完成并参与调度从内核态返回用户态时,就会执行信号处理,自然就会恰到好处地停下来。 ```bash man 2 ptrace PTRACE_SETOPTIONS (since Linux 2.4.6; see BUGS for caveats) **PTRACE_O_TRACECLONE**: Stop the tracee at the next clone(2) and automatically start tracing the newly cloned process, which will start with a SIGSTOP, or PTRACE_EVENT_STOP if PTRACE_SEIZE was used. ``` 在上述代码基础上做下列修改就可以搞定新线程创建时自动跟踪了: > ```go > pid := progCmd.Process.Pid > if _, err := syscall.Wait4(pid, &status, syscall.WALL, &rusage); err != nil { > fmt.Println(err) > os.Exit(1) > } > syscall.PtraceSetOptions(pid, syscall.PTRACE_O_TRACECLONE) > ``` 此时go程序中创建新线程时,Linux会执行到如下处理逻辑: ```c /* * Ok, this is the main fork-routine. * * It copies the process, and if successful kick-starts * it and waits for it to finish using the VM if required. * * args->exit_signal is expected to be checked for sanity by the caller. */ pid_t kernel_clone(struct kernel_clone_args *args) { // 创建出新线程 p = copy_process(NULL, trace, NUMA_NO_NODE, args); pid = get_task_pid(p, PIDTYPE_PID); if (clone_flags & CLONE_VFORK) { p->vfork_done = &vfork; init_completion(&vfork); get_task_struct(p); } // housekeeping并将其加入调度器待调度的任务队列中 wake_up_new_task(p); /* forking complete and child started to run, tell ptracer */ // 给tracee发送信号SIGTRAP if (unlikely(trace)) ptrace_event_pid(trace, pid); if (clone_flags & CLONE_VFORK) { if (!wait_for_vfork_done(p, &vfork)) ptrace_event_pid(PTRACE_EVENT_VFORK_DONE, pid); } } static inline void ptrace_event_pid(int event, struct pid *pid) { ptrace_event(event, message); } static inline void ptrace_event(int event, unsigned long message) { if (unlikely(ptrace_event_enabled(current, event))) { current->ptrace_message = message; ptrace_notify((event << 8) | SIGTRAP); } else if (event == PTRACE_EVENT_EXEC) { ... } } ``` 首先会创建一个线程,然后完成一些housekeeping逻辑并将新线程放入调度器的任务队列中,等待被调度,然后通过ptrace_event_pid发送SIGTRAP给这个新线程。OK,一切准备就绪,之后就是kernel_clone系统调用执行结束返回时,系统调用结束时就是内核发起新一轮调度的一个契机,如果新线程被调度器调度到、新线程返回用户态开始执行之前,首先就是要处理这个SIGTRAP信号,自然就会停下来并通知ptracer。通知ptracer的这部分我们前面已经提过了,这里不再赘述。 > ps: 各种类型任务的切换时机,联想下: > > - 中断服务程序切换,指令周期的结束CPU会检查有没有新的中断控制器发来的中断请求; > - 线程切换,操作系统内核系统调用时钟中断、系统调用返回之前会检查是否需要执行当前任务,还是切换到另一个任务,此时还会检查有没有pending信号要处理; > - 协程切换,在goroutine进行网络IO、或者涉及到goroutines之间执行同步操作的交互逻辑时,检查是否需要暂停当前goroutine并调度其他goroutine来执行; ### 重新思考ptrace limit 前一节我们思考过ptrace limit,即当ptrace link建立后,debugger要发送给tracee的所有ptrace requests都只能通过tracer对应的线程来发送。 ptrace link建立的方式有两种: 1、一种是ptrace attach去跟踪已经运行中的线程; 2、一种是主动启动构建好的程序,通过fork+exec的方式,子进程中主动调用ptrace traceme; 这两种方式都需要考虑这里的ptrace约束:所有后续ptrace请求都只能通过一个ptracer发送。那就要求我们设计一个公共的helper函数,比如 `func execPtraceAction(func () error) error`,我们可以内部维护一个chan,这个chan会接受调用方发送来的所有ptrace操作函数,然后我们启动一个goroutine,这个goroutine一开始就设置好 `runtime.LockOSThread()` ,然后它慢慢消费这个ptrace操作函数队列,取出一个执行一个,并设置好是否发生了error。 ```go var ( once sync.Once reqCh = make(chan func() error, 1) doneCh = make(chan error, 1) ) func execPtrace(fn func() error) error { once.Do(func() { go func() { // ensure all ptrace requests goes via the same tracer (thread) runtime.LockOSThread() defer runtime.UnlockOSThread() // polling ptrace actions and run them for { select { case req := <-reqCh: req.errCh <- req.fn() case <-p.doneCh: break } } }() }) // submit ptrace action req := ptraceRequest{ fn: fn, errCh: make(chan error), } reqCh <- req // wait ptrace action finished return <-req.errCh } ``` 比较特殊的是,通先启动子进程,然后子进程主动调用ptrace traceme这种方式,执行启动子进程逻辑的线程自动就成了ptracer,所以为了保证后续ptrace requests能和这个ptracer是同一个线程上执行的。我们也要将启动子进程逻辑放在上述函数中执行。比如: ```go var err = execPtrace(func() error) { // start process but don't wait it finished progCmd := exec.Command(args[0]) progCmd.Stdin = os.Stdin progCmd.Stdout = os.Stdout progCmd.Stderr = os.Stderr progCmd.SysProcAttr = &syscall.SysProcAttr{ Ptrace: true, // this implies PTRACE_TRACEME } return progCmd.Start() }) ``` **ps:注意wait4不受这个ptrace limit限制,前面分析过了,ptracer所属进程中任意线程都可以调用wait4且能被正常唤醒。** ### 本节小结 本节深入探讨了调试器对多线程程序进行跟踪的机制,第1节介绍了调试器如何启动程序,第2节介绍了如何attach到运行中的进程,而本节则重点阐述了如何在程序启动时立即发起跟踪,以及如何实现对未来新创建线程的自动跟踪。 **启动时立即跟踪的关键**:通过 `PTRACE_TRACEME` 机制,我们可以在程序执行第一条指令之前就将其暂停,这为调试器提供了在程序初始化阶段设置断点的机会。内核通过发送 `SIGTRAP` 信号来暂停被跟踪进程,并通过 `SIGCHLD` 信号或直接唤醒机制通知调试器。 **多线程自动跟踪的实现**:对于多线程程序的调试,关键在于及时设置 `PTRACE_O_TRACECLONE` 选项。当tracee执行完 `PTRACE_TRACEME` 并通知tracer后,tracer应立即调用 `syscall.PtraceSetOptions(traceePID, syscall.PTRACE_O_TRACECLONE)` 来配置跟踪选项。这样,当tracee内部创建新线程时,内核会自动跟踪新线程并通知tracer。通过递归应用这一机制,我们甚至可以实现对"新线程继续创建新线程"的跟踪,从而确保单进程多线程调试的完整覆盖。 **内核层面的信号处理**:本节还深入分析了Linux内核中信号处理的细节,包括同步信号与异步信号的区别、`SIGTRAP` 信号的特殊处理机制,以及内核如何通过来实现进程暂停和通知唤醒机制。 通过本节的学习,读者不仅掌握了调试器开发中进程跟踪的核心技术,还深入理解了Linux操作系统在信号处理和进程管理方面的底层实现细节。现在我们已经掌握了如何跟踪程序,接下来就应该建立调试会话,调试会话中维护了一系列实用的调试命令,我们可以通过添加断点、执行到断点、打印变量、寄存器、线程切换、协程切换等等调试命令来自由调试。下一节我们将学习如何建立一个既实用便捷又可以灵活扩展调试命令的调试会话。 ### 参考内容 - Playing with ptrace, Part I, Pradeep Padala, https://www.linuxjournal.com/article/6100 - Playing with ptrace, Part II, Pradeep Padala, https://www.linuxjournal.com/article/6210 - Understanding Linux Execve System Call, Wenbo Shen, https://wenboshen.org/posts/2016-09-15-kernel-execve.html ================================================ FILE: book/6-develop-inst-debugger/4-debug-session.md ================================================ ## 调试会话: 交互式调试会话 ### 实现目标:建立交互式调试会话 为了使调试过程更方便,我们需要建立一个高效便捷的交互式调试会话,在调试会话中可以输入各种调试命令 `godbg> cmd [flags] [args]`。 这里有一些功能性、非功能性的需求: - 能记住上次执行的调试命令,敲“回车”时可重复执行该命令避免再次输入; - 能记住执行的命令列表,方便通过“上下按键”快速查找最近使用的命令; - 能记住要显示的变量名列表,以便执行调试时能同时跟踪多个变量的值; - 能记住曾经添加过的断点,以便快速查看断点列表,或者有选择地清除断点; - 能维护调试有关的命令列表及其帮助信息,方便快速查看、掌握命令使用; - 当一轮调试结束后,希望能根据当前调试过程快速发起新一轮调试; - 在一轮调试过程中,希望能将当前调试状态回退几步,以回到之前的某个状态; - 用户键入调试命令、参数信息应尽可能方便,要降低使用成本、记忆成本; - 其他; 我们其实还可以想出更多的点子,而这些点子背后,其实是希望调试器能提供一个灵活的用户界面,它不仅为调试过程提供各种调试命令及使用帮助,也能维护当前调试过程的状态以提供更多更丰富的能力。 这其实就是一个调试会话,会话记录跟踪用户的调试设置、状态,更方便地协助用户完成调试,我们需要为用户构建一个调试会话。 ### 代码实现 当我们的调试器(tracer)成功跟踪了被调试进程(tracee)时,就可以创建调试会话了。 `godbg exec `,当通过这种方式启动并跟踪了一个进程后,我们可以实现 `debug.NewDebugShell().Run()`来创建并启动一个调试会话。本小节我们介绍下调试会话的实现细节,读者了解后将能够熟悉cobraprompt的使用,或者使用liner能代替cobraprompt来实现不同风格的调试会话。 #### cobra管理调试命令 在前面1、2、3小节中,我们演示的示例程序中是通过os.Args[1]来判断godbg的子命令并通过switch-case转入对应的处理逻辑的。当我们实现了调试回话之后,我们也需要频繁读取用户输入的其他命令,如break、continue、next等等,其实每一个命令就对应着一个不同的处理逻辑,如果我们都像前面几个小节这样写,我们的代码很快就将变得冗长且难以招架。 前面我们曾经提到过通过cobra这个命令行框架来管理调试命令exec, attach, help 等,还有调试会话中的诸多调试命令。 首先,不妨看一个使用cobra构建命令行工具的简单示例: ```go package main import ( "fmt" "github.com/spf13/cobra" ) func main() { // 创建根命令 var rootCmd = &cobra.Command{ Use: "demo", Short: "一个基于cobra开发的命令行应用", Long: `spf13/cobra是一个非常容易上手的go命令行工具开发框架,这是一个展示spf13/cobra用法的demo程序。`, } // 创建一个版本命令 var versionCmd = &cobra.Command{ Use: "version", Short: "打印版本信息", Long: `version子命令将打印应用的版本信息`, Run: func(cmd *cobra.Command, args []string) { fmt.Println("Demo App v1.0") }, } // 将版本命令添加为根命令的子命令 rootCmd.AddCommand(versionCmd) // 执行根命令 rootCmd.Execute() } ``` 在上述示例中,我们: 1. 创建一个根命令"demo" 2. 创建一个"version"子命令用于打印版本信息 3. 将"version"命令添加为"demo"的子命令 4. 执行根"demo"命令 这将构建一个简单的命令行工具,它有一个"version"子命令用于打印版本字符串。Cobra使构建命令行接口变得简单。我们可以继续在根命令上添加更多子命令来构建功能。 在实际开发中,为了快速添加命令、子命令,可以使用 [spf13/cobra-cli](https://github.com/spf13/cobra-cli) 来完成,如 `cobra-cli add xxxCmd --parent rootCmd`,这将为rootCmd添加一个子命令xxxCmd,当然也可以为xxxCmd添加子命令。 实际上读到这里时,我们已经基于 spf13/cobra 命令行管理框架对godbg进行了重构,使其更易于管理命令及实现。在后续的调试会话的示例中就可以看到。 #### cobraprompt自动补全 基于cobraprompt实现调试会话,看中的是其自动补全这个便利性,实现思路相对来说也比较简单: - 利用cobra的命令管理能力,我们创建一个debugRootCmd,在其上注册其他的调试命令,如breakpoint、list、step、continue、exit等等,每一个子命令都对应着一个cobra.Command实现; - prompt具备提示用户输入、获取并校验用户输入、动态补全输入的能力,cobraprompt是cobra和prompt的结合体,它的作用便是利用prompt来获取用户输入的命令,输入过程中支持自动补全,在用户输入正确的调试命令及参数时,将参数信息转交给debugRootCmd上注册的相应子命令来执行,如用户键入了exit,则执行exitCmd的逻辑; - 为了方便用户快速查看调试命令的帮助信息,需对诸多的调试命令进行分组,因此对于调试命令的帮助信息需要进行定制化改造,我们用各个子命令的annotation来对其所属的命令分组(如断点类命令1-breaks、源码类命令2-source),然后通过自定义的帮助函数 `helpMessageByGroups(rootCmd *cobra.Command)`来提取rootCmd下注册所有子命令的帮助信息并按分组进行汇总展示; - 对具体一个子命令的详细帮助信息,则还是交由cobra debugRootCmd自身来管理; ```go package debug import ( "bytes" "fmt" "sort" "strings" "github.com/c-bata/go-prompt" "github.com/hitzhangjie/godbg/target" cobraprompt "github.com/stromland/cobra-prompt" "github.com/spf13/cobra" ) const ( cmdGroupAnnotation = "cmd_group_annotation" cmdGroupBreakpoints = "1-breaks" cmdGroupSource = "2-source" cmdGroupCtrlFlow = "3-execute" cmdGroupInfo = "4-info" cmdGroupOthers = "5-other" cmdGroupCobra = "other" cmdGroupDelimiter = "-" prefix = "godbg> " descShort = "godbg interactive debugging commands" ) const ( suggestionListSourceFiles = "ListSourceFiles" ) var ( TraceePID int breakpoints = map[uintptr]*target.Breakpoint{} ) var debugRootCmd = &cobra.Command{ Use: "help [command]", Short: descShort, } // NewDebugShell 创建一个debug专用的交互管理器 func NewDebugShell() *cobraprompt.CobraPrompt { fn := func(cmd *cobra.Command, args []string) { // 描述信息 fmt.Println(cmd.Short) fmt.Println() // 使用信息 fmt.Println(cmd.Use) fmt.Println(cmd.Flags().FlagUsages()) // 命令分组 usage := helpMessageByGroups(cmd) fmt.Println(usage) } debugRootCmd.SetHelpFunc(fn) return &cobraprompt.CobraPrompt{ RootCmd: debugRootCmd, DynamicSuggestionsFunc: dynamicSuggestions, ResetFlagsFlag: false, GoPromptOptions: []prompt.Option{ prompt.OptionTitle(descShort), prompt.OptionPrefix(prefix), prompt.OptionSuggestionBGColor(prompt.DarkBlue), prompt.OptionDescriptionBGColor(prompt.DarkBlue), prompt.OptionSelectedSuggestionBGColor(prompt.Red), prompt.OptionSelectedDescriptionBGColor(prompt.Red), // here, hide prompt dropdown list // TODO do we have a better way to show/hide the prompt dropdown list? prompt.OptionMaxSuggestion(10), prompt.OptionShowCompletionAtStart(), prompt.OptionCompletionOnDown(), }, EnableSilentPrompt: true, EnableShowAtStart: true, } } // helpMessageByGroups 将各个命令按照分组归类,再展示帮助信息 func helpMessageByGroups(cmd *cobra.Command) string { // key:group, val:sorted commands in same group groups := map[string][]string{} for _, c := range cmd.Commands() { // 如果没有指定命令分组,放入other组 var groupName string v, ok := c.Annotations[cmdGroupAnnotation] if !ok { groupName = "other" } else { groupName = v } groupCmds, ok := groups[groupName] groupCmds = append(groupCmds, fmt.Sprintf(" %-16s:%s", c.Name(), c.Short)) sort.Strings(groupCmds) groups[groupName] = groupCmds } if len(groups[cmdGroupCobra]) != 0 { groups[cmdGroupOthers] = append(groups[cmdGroupOthers], groups[cmdGroupCobra]...) } delete(groups, cmdGroupCobra) // 按照分组名进行排序 groupNames := []string{} for k, _ := range groups { groupNames = append(groupNames, k) } sort.Strings(groupNames) // 按照group分组,并对组内命令进行排序 buf := bytes.Buffer{} for _, groupName := range groupNames { commands, _ := groups[groupName] group := strings.Split(groupName, cmdGroupDelimiter)[1] buf.WriteString(fmt.Sprintf("- [%s]\n", group)) for _, cmd := range commands { buf.WriteString(fmt.Sprintf("%s\n", cmd)) } buf.WriteString("\n") } return buf.String() } func dynamicSuggestions(annotation string, _ prompt.Document) []prompt.Suggest { switch annotation { case suggestionListSourceFiles: return GetSourceFiles() default: return []prompt.Suggest{} } } func GetSourceFiles() []prompt.Suggest { return []prompt.Suggest{ {Text: "main.go", Description: "main.go"}, {Text: "helloworld.go", Description: "helloworld.go"}, } } ``` #### peterh/liner自动补全 已经有了基于cobraprompt的自动补全实现了,那我们为什么又提供一个基于liner实现的版本呢?在作者初次尝试实现调试会话时,是基于cobraprompt,当时作者认为cobraprompt的这种自动输入补全能力非常方便和酷炫,所以对其称赞有加。但是随着后续调试活动的增加,作者逐渐意识到:**调试过程中保持用户对问题探求的专注、减少对用户的干扰,比单纯地追求酷炫、方便本身更重要**。 所以作者对原来的调试会话进行了重写,即基于liner实现的这个版本,读者阅读代码即可发现,其实现思路和cobraprompt其实差别不大,换了种调试会话风格,同时帮我们解决了一个输入“回车”时方便判断用户输入并激活上次调试命令的问题,在cobraprompt中用户是无法只键入“回车”按键的。 > 注:基于cobraprompt、liner的自动补全实现方案,在项目代码 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中可以找到,最终实现方案是基于liner的,前期是基于cobraprompt的,读者可以根据需要自行查阅。 > > ```bash > zhangjie@vbox 0_godbg(eda43a2) $ git logxx --grep 'cobra-prompt' > > * cbccb23 2021-01-14 00:56:59 +0800 >>> refactor: use cobra+liner instead of cobra-prompt > * 8c07fbd 2020-11-16 03:32:32 +0800 >>> feature: 初步实现了一个基于cobra+cobra-prompt实现的调试器大致框架 > ``` 下面看下基于liner的具体实现。 ```go package debug import ( "bytes" "fmt" "sort" "strings" "github.com/peterh/liner" "github.com/spf13/cobra" ) const ( cmdGroupAnnotation = "cmd_group_annotation" cmdGroupBreakpoints = "1-breaks" cmdGroupSource = "2-source" cmdGroupCtrlFlow = "3-execute" cmdGroupInfo = "4-info" cmdGroupOthers = "5-other" cmdGroupCobra = "other" cmdGroupDelimiter = "-" prefix = "godbg> " descShort = "godbg interactive debugging commands" ) const ( suggestionListSourceFiles = "ListSourceFiles" ) var debugRootCmd = &cobra.Command{ Use: "help [command]", Short: descShort, } var ( CurrentSession *DebugSession ) // DebugSession 调试会话 type DebugSession struct { done chan bool prefix string root *cobra.Command liner *liner.State last string defers []func() } // NewDebugSession 创建一个debug专用的交互管理器 func NewDebugSession() *DebugSession { fn := func(cmd *cobra.Command, args []string) { // 描述信息 fmt.Println(cmd.Short) fmt.Println() // 使用信息 fmt.Println(cmd.Use) fmt.Println(cmd.Flags().FlagUsages()) // 命令分组 usage := helpMessageByGroups(cmd) fmt.Println(usage) } debugRootCmd.SetHelpFunc(fn) return &DebugSession{ done: make(chan bool), prefix: prefix, root: debugRootCmd, liner: liner.NewLiner(), last: "", } } func (s *DebugSession) Start() { s.liner.SetCompleter(completer) s.liner.SetTabCompletionStyle(liner.TabPrints) defer func() { for idx := len(s.defers) - 1; idx >= 0; idx-- { s.defers[idx]() } }() for { select { case <-s.done: s.liner.Close() return default: } txt, err := s.liner.Prompt(s.prefix) if err != nil { panic(err) } txt = strings.TrimSpace(txt) if len(txt) != 0 { s.last = txt s.liner.AppendHistory(txt) } else { txt = s.last } s.root.SetArgs(strings.Split(txt, " ")) s.root.Execute() } } func (s *DebugSession) AtExit(fn func()) *DebugSession { s.defers = append(s.defers, fn) return s } func (s *DebugSession) Stop() { close(s.done) } func completer(line string) []string { cmds := []string{} for _, c := range debugRootCmd.Commands() { // complete cmd if strings.HasPrefix(c.Use, line) { cmds = append(cmds, strings.Split(c.Use, " ")[0]) } // complete cmd's aliases for _, alias := range c.Aliases { if strings.HasPrefix(alias, line) { cmds = append(cmds, alias) } } } return cmds } // helpMessageByGroups 将各个命令按照分组归类,再展示帮助信息 func helpMessageByGroups(cmd *cobra.Command) string { // key:group, val:sorted commands in same group groups := map[string][]string{} for _, c := range cmd.Commands() { // 如果没有指定命令分组,放入other组 var groupName string v, ok := c.Annotations[cmdGroupAnnotation] if !ok { groupName = "other" } else { groupName = v } groupCmds, ok := groups[groupName] groupCmds = append(groupCmds, fmt.Sprintf(" %-16s:%s", c.Name(), c.Short)) sort.Strings(groupCmds) groups[groupName] = groupCmds } if len(groups[cmdGroupCobra]) != 0 { groups[cmdGroupOthers] = append(groups[cmdGroupOthers], groups[cmdGroupCobra]...) } delete(groups, cmdGroupCobra) // 按照分组名进行排序 groupNames := []string{} for k, _ := range groups { groupNames = append(groupNames, k) } sort.Strings(groupNames) // 按照group分组,并对组内命令进行排序 buf := bytes.Buffer{} for _, groupName := range groupNames { commands, _ := groups[groupName] group := strings.Split(groupName, cmdGroupDelimiter)[1] buf.WriteString(fmt.Sprintf("- [%s]\n", group)) for _, cmd := range commands { buf.WriteString(fmt.Sprintf("%s\n", cmd)) } buf.WriteString("\n") } return buf.String() } ``` ### 代码测试 #### 基于cobraprompt实现 尽管基于cobraprompt的实现已经被作者废弃,但是为了这个前后内容的一致性,既然讲了实现了,那运行效果也得介绍下。再者,可能读者对这种风格的自动补全感兴趣,所以还是演示下运行效果。 调试器启动成功后,会通过“**godbg>**”来表示当前创建好的调试会话,我们在此调试会话中输入调试命令来完成对应的调试动作。 > 如果你想亲自测试这部分代码,需要检出 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 的版本 8c07fbd,然后项目目录下执行 `go install -v` 完成安装,再进行测试。 > > ```bash > $ git logxx --grep 'cobra-prompt' > > 8c07fbd 2020-11-16 03:32:32 +0800 >>> feature: 初步实现了一个基于cobra+cobra-prompt实现的调试器大致框架 > ``` ![godbg prompt showAtStart](assets/godbg_prompt1.png) 以清除断点操作为例,clear是清除单个断点,clearall是清除所有的断点,当我们输入 `cl`时,可以匹配到 `clear`、`clearall`两个命令,开发人员可以通过 `tab`按键或者 `arrow-down`来在候选列表中移动,`enter`选中列表项。 ![godbg prompt commands](assets/godbg_prompt2.png) 再看一个命令参数层面自动补全的例子,以list查看源码命令为例,此时会返回进程中涉及到的源码信息,如此处有main.go helloworld.go,方便用户选择,调试时就更简单了。 ![godbg prompt suggestions](assets/godbg_prompt3.png) 这是关于调试会话界面的运行效果展示。 #### 基于peterh/liner实现 这部分代码位于repo [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg)下,执行 `go install -v`即可进行体验。 调试器启动后创建一个调试会话,调试会话仍然以“**godbg>** ”作为输入提示符,当我们输入了help命令时,它显示分组后的各个调试命令的帮助信息,非常直观。 ![godbg prompt](assets/godbg_prompt4.png) 当我们想查看具体某个调试命令的详细使用信息时,可以执行 `help `的方式来查看,如以查看反汇编命令的帮助信息为例,图中显示了disass命令的使用格式、参数列表及各自说明。 ![godbg prompt5](assets/godbg-prompt5.png) 再来看下自动补全提示,调试器中假定已经支持了断点命令breakpoint(其别名是break或b),所有断点命令breakpoints(其别名是bs),调用栈命令backtrace(其别名是bt)。 那么当我们输入了字符“b”之后,我们按下“TAB”之后,此时可以看到断点、调用栈等相关的命令及其别名形式已经全部展示出来了;此时用户可以获得提示还可以如何进行输入,比如如果是断点类型的按照提示还可以输入字母“r”,当我们输入了“r”之后,继续输入“TAB”此时就可以自动补全到“break”,还有多个候选项,此时可以继续键入字母“p”,可以自动补全到“breakpoint”。假设说自己想输入的就是“breakpoint”则直接敲回车即可。 ![godbg prompt6](assets/godbg-prompt6.png) #### 简单对比 通过代码测试环节,我们对基于cobraprompt以及liner两种方式实现的调试会话进行了个简单的对比: - 基于cobraprompt的方式随酷炫但是对调试过程干扰比较大,可能更容易导致用户抓不住重点,不便于用户保持对问题本身的探求和关注; - 基于liner的方式则更加精炼,对用户打扰也更少,也具备了我们期望拥有的自动补全能力,能够满足我们最初设计的调试会话的要求,因此将其作为最终实现版本, #### 配套代码 也跟读者强调一下,随着我们添加的功能越来越多、功能间依赖越来越多、代码量也越来越多,我们的代码示例简单到一个源文件就可以搞定的情况会越来越少。也就是说,读者后续测试的用例可能会有相当一部分要考虑使用 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 来直接进行测试验证。 为了读者阅读学习的便利性,我们会尽量按照这个形式规范: - repo1 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 会包含每个调试命令的实现; - repo2 [hitzhangjie/golang-debugger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons) 如果demo可以简化到一个源文件,我们会尽量repo2也包含一个对应的测试用例。或者需要多步调试器操作,为了方便读者一键运行测试,会将调试器核心操作所步骤在测试用例中以代码的形式给出来; - repo3 [hitzhangjie/tinydbg] 这个是dlv大幅裁剪、精简后的符号级调试器实现; golang-debugger-lessons这个repo中的每个示例都是完全独立的,因此你可以自由修改测试,而不用担心把godbg整个项目修改的跑不起来了、运行不正常了、查问题又没有头绪,更适合新手学习测试。您可以在具备一定调试器开发经验后,再去查看、修改 godbg 这个项目。而当进入到符号级调试器开发领域时,我们将使用repo [hitzhangjie/tinydbg] 这个经过dlv裁剪精简后的版本进行学习、测试。 ps: 如果示例仅包含在repo1,我们在相关小节的设计实现、代码测试时会明确告知读者如何测试。 ### 本节小结 本节围绕调试器的“调试会话”交互界面展开,详细介绍了两种命令行交互实现方式:基于 cobra-prompt 的酷炫交互和基于 liner 的精炼交互。通过实际效果截图和代码示例,展示了命令自动补全、参数提示、分组帮助等功能如何提升调试体验。对比分析后,指出 liner 方案更适合调试器场景,既能满足自动补全等核心需求,又能减少对用户的干扰。通过本节内容,读者可以系统了解调试器命令行会话的设计思路和实现细节,为后续深入开发和使用打下坚实基础。 接下来我们将学习如何在调试会话中扩展不同的调试命令,如反汇编、添加断点、列出断点、清理断点、步进、执行到断点、读写内存、读写寄存器等调试命令。指令级调试和符号级调试器相比虽然实现起来更简单、工作量也小,但是不要小瞧这部分内容,tracer->kernel->tracee这条控制路径上的交互逻辑是几乎相同的,掌握了指令级调试的这部分内容就扫清了这部分路径上的疑障。等到介绍符号级调试器实现时,我们的技术路径将更加完善 debugger frontend->RPC->debugger backend (tracer with DWARF support)->kernel->tracee。 ### 参考内容 - cobra, https://github.com/spf13/cobra - go-prompt, https://github.com/c-bata/go-prompt - cobra-prompt, https://github.com/stromland/cobra-prompt - liner, https://github.com/peterh/liner ================================================ FILE: book/6-develop-inst-debugger/5-disassemble.md ================================================ ## 调试命令:`disass` 反汇编 ### 实现目标:`godbg> disass [address]` 实现反汇编 反汇编是指令级调试过程中不可缺少的环节,对于符号级调试需要展示源码信息,对于指令级调试而言就是要展示汇编指令了。 汇编指令是和硬件强相关的,其实汇编指令不过是些助记符,一条汇编指令的操作码、操作数通过规定的编码方式进行编码,就得到了机器指令。不同指令的操作码占用字节数可能是相同的,也可能是不同的,操作数占用字节数也可能相同或不同。 不同硬件平台、指令集架构、诸多的汇编指令以及指令的定长编码、变长编码等等因素都给反汇编带来了一定的难度,要想凭一人之力实现反汇编还真的不是一件容易的事情。 幸好已经有反汇编框架[Capstone](http://www.capstone-engine.org/)来专门解决这个问题,对于go语言而言可以考虑使用go版本的[Gapstone](https://github.com/bnagy/gapstone)。或者,我们使用go官方提供的反汇编工具类[arch/x86/x86asm](https://golang.org/x/arch/x86/x86asm),注意到在流行的go语言调试器dlv里面也是使用x86asm进行反汇编操作的。 为了简单起见,我们也将使用arch/x86/x86asm来完成反汇编任务,当然使用Capstone、Gapstone也并非不可以,如果读者感兴趣可以自行实验。 ### 代码实现 实现反汇编操作,主要是要掌握这几个操作: - 如何读取指定进程的完整指令数据; - 如何对一个完整的程序进行反汇编操作; - 如何对断点处指令进行反汇编操作。 #### 根据pid找到可执行程序 tracer对tracee的控制,很多操作都依赖tracee的pid,如果要读取pid对应的可执行程序的完整指令数据,那就必须先通过pid找到对应的可执行程序路径,怎么做呢? > 直接读取pid对应进程实例的内存数据是没用的,因为里面的指令数据可能不全。进程的指令数据也是按需加载的,详细可了解下Linux PageFault相关内容。 在Linux系统下,虚拟文件系统路径 `/proc//exe`是一个符号链接,它指向了进程 `pid`对应的可执行程序的绝对路径。go程序里读取该符号链接信息即可获知程序路径。 比如这样操作: ```go package main // GetExecutable 根据pid获取可执行程序路径 func GetExecutable(pid int) (string, error) { exeLink := fmt.Sprintf("/proc/%d/exe", pid) exePath, err := os.Readlink(exeLink) if err != nil { return "", fmt.Errorf("find executable by pid err: %w", err) } return exePath, nil } ``` #### 实现对完整程序反汇编 根据pid找到可执行程序文件路径之后,可以尝试读取文件内容,为接下来反汇编做准备。但要注意的是,Linux二进制可执行程序文件内容是按照 `ELF (Executable and Linkable Format)`格式进行组织的,其大致的结构如下所示。要读取、解析ELF文件数据,可以借助标准库 `debug/elf` 来完成。 ![elf](assets/elf_layout.png) 我们看到一个ELF文件包含了ELF Header、Program Header Table、Section Header Table以及一系列的Sections,Program Header Table、Section Header Table中引用Sections。 Program Header Table和Section Header Table,是为构建两种不同视图特意设计的: - Program Header Table构造了segments视图,它描述了程序加载时loader如何通过mmap将引用的sections映射到内存空间; - Section Header Table构造了sections视图,描述了二进制程序中sections的顺序位置,以及指导linker如何进行链接; 这两种视图互不干扰,分别用于指导loader、linker的设计实现。 > **What You Need To Know About ELF for CS452** > > **Two views of the world** > > There are two views of an ELF file. 1) The *section view* sees the file as a bunch of sections, which are to be linked or loaded in some manner. 2) The *program view* sees the file as a bunch of *ELF segments* (not to be confused with Intel segments) which are to be loaded into memory in order to execute the program. > > This split is designed to allow someone writing a linker to easily get the information they need (using the section view) and someone writing a loader (that's you) easily get the information they need without worrying about a lot of the complications of linking (using the program view). > > Because you are writing a loader, not a linker, **you can completely ignore the section view**. You only care about the program view. This throws away around 80% of the ELF spec. Doesn't that make you feel good? > > see: https://student.cs.uwaterloo.ca/~cs452/W18/elf/elf.html 现在我们要想实现反汇编操作的话,就必须先将可执行程序(Linux下是ELF格式)按照ELF格式正常解析,再从.text segment读取出机器指令。 下面我们就来做这个事情: ```go package main import ( "debug/elf" "fmt" "os" "strconv" "golang.org/x/arch/x86/x86asm" ) func main() { // go run main.go if len(os.Args) != 2 { panic("invalid params") } // pid pid, err := strconv.Atoi(os.Args[1]) if err != nil { panic(err) } // 通过pid找到可执行程序路径 exePath, err := GetExecutable(pid) if err != nil { panic(err) } fmt.Println(exePath) // 读取指令信息并反汇编 elfFile, err := elf.Open(exePath) if err != nil { panic(err) } section := elfFile.Section(".text") buf, err := section.Data() if err != nil { panic(err) } // 逐语句解析机器指令并反汇编,然后打印出来 offset := 0 for { // 使用64位模式 inst, err := x86asm.Decode(buf[offset:], 64) if err != nil { panic(err) } fmt.Printf("%8x %s\n", offset, inst.String()) offset += inst.Len } } ``` 这里的代码逻辑比较完整,它接收一个pid,然后获取对应的可执行文件路径,然后通过标准库提供的elf package来读取文件并按ELF文件进行解析。从中读取.text section的数据。众所周知,.text section内部数据即为程序的指令。 拿到指令之后,我们就可以通过 `golang.org/x/arch/x86/x86asm`来进行反汇编操作了,因为指令是变长编码,反汇编成功后,返回的信息中包含了当前指令数据的字节数,我们借此调整偏移量后可以对后续指令继续反汇编。 #### 对断点位置进行反汇编 对断点位置进行反汇编,首要任务就是获得当前断点的位置。 动态断点,往往是通过指令patch来实现的,即将任意完整机器指令的第一字节数据保存,然后将其替换成 `0xCC (int 3)`指令,处理器执行完0xCC之后自身就会停下来,这就是断点的效果。 断点通过指令patch来实现必须覆盖指令的第一字节,不能覆盖其他字节,原因很简单,指令为了提高解码效率、支持更多操作类型,往往都是采用的变长编码。如果不写第一字节,那么处理器执行时可能会产生错误。比如一条指令操作码有多个字节,结果因为覆盖的原因导致变成了一个错误的操作码,执行时就会有异常。再比如一条指令只有一个字节,我们非要写到第二个字节存起来,那就起不到断点的作用,因为执行到这个断点时,前面本不应该执行的一字节指令却执行了。 前面我们有系统性地介绍过指令patch的概念、应用场景等(比如调试器、mock测试框架gomonkey等等),如您还感到不熟悉,请回头查看相关章节。 假如说当前我们的断点位于offset处,现在要执行反汇编动作,大致有如下步骤: ```bash 断点添加之前: offset: 0x0 0x1 0x2 0x3 0x4 断点添加之后: offset: 0xcc 0x1 0x2 0x3 0x4 | orig: ``` - 首先,要知道0xCC执行后会暂停执行,执行后,意味着此时PC=offset+1 - 再次,要知道PC-1处的指令不是完整指令,第一字节指令被patch了,需要还原; - 最后,要知道PC值是特殊寄存器值,指向待执行指令的位置,当前PC=offset+1,是原指令起始字节的下个字节位置,要从 PC-1 这个内存位置开始读取完整指令,再反汇编; 这大概就是断点位置处执行反汇编所需要的操作,如果对应位置处不是断点就不需要执行 `pc=pc-1`。 #### Put It Together 经过上面一番讨论之后,反汇编命令disassCmd实现如下: ```go package debug import ( "fmt" "os" "syscall" "github.com/spf13/cobra" "golang.org/x/arch/x86/x86asm" ) var disassCmd = &cobra.Command{ Use: "disass ", Short: "反汇编机器指令", Annotations: map[string]string{ cmdGroupKey: cmdGroupSource, }, RunE: func(cmd *cobra.Command, args []string) error { // 读取PC值 regs := syscall.PtraceRegs{} err := syscall.PtraceGetRegs(TraceePID, ®s) if err != nil { return err } buf := make([]byte, 1) n, err := syscall.PtracePeekText(TraceePID, uintptr(regs.PC()-1), buf) if err != nil || n != 1 { return fmt.Errorf("peek text error: %v, bytes: %d", err, n) } fmt.Printf("read %d bytes, value of %x\n", n, buf[0]) // PC前一字节为0xCC表示当前PC应回退1字节才能读取到完整指令数据 if buf[0] == 0xCC { regs.SetPC(regs.PC() - 1) } // 查找,如果之前设置过断点,将恢复 dat := make([]byte, 1024) n, err = syscall.PtracePeekText(TraceePID, uintptr(regs.PC()), dat) if err != nil { return fmt.Errorf("peek text error: %v, bytes: %d", err, n) } fmt.Printf("size of text: %d\n", n) // TODO 在实现了断点功能之后,需注意读取到的dat[0]为0xCC,此时需注意还原指令数据, // 否则反汇编操作是有错误的。 // 反汇编这里的指令数据 offset := 0 for offset < n { // 使用64位模式 inst, err := x86asm.Decode(dat[offset:], 64) if err != nil { return fmt.Errorf("x86asm decode error: %v", err) } fmt.Printf("%8x %s\n", offset, inst.String()) offset += inst.Len } }, } func init() { debugRootCmd.AddCommand(disassCmd) } // GetExecutable 根据pid获取可执行pa程序路径 func GetExecutable(pid int) (string, error) { exeLink := fmt.Sprintf("/proc/%d/exe", pid) exePath, err := os.Readlink(exeLink) if err != nil { return "", fmt.Errorf("find executable by pid err: %w", err) } return exePath, nil } ``` ### 代码测试 我们随便写一个go程序,让其运行起来,查看其pid为2507,随后执行 `godbg attach 2507`开始对目标进程进行调试。 调试会话启动之后,我们直接输入disass命令进行反汇编,可以看到所有指令的反汇编数据。我们当前还没有实现 `breakpoint` 功能,所以此处先不演示对断点处进行反汇编的效果,示例代码中也已经进行了说明,如果PC-1处为断点,需要注意将对应位置的0xCC给还原。 > TODO 这里需要在实现了断点功能之后,再补充一个小节继续完善下断点处反汇编功能的实现。涉及到从断点位置读取指令数据的部分、指令patch数据的备份还原、反汇编。 ```bash $ godbg attach 2507 process 2507 attached succ process 2507 stopped: true godbg> disass read 1 bytes, value of 89 size of text: 1024 0 MOV [RSP+Reg(0)+0x20], EAX 4 RET 5 INT 0x3 .......... 1e INT 0x3 1f INT 0x3 20 MOV EDI, [RSP+Reg(0)+0x8] 24 MOV RSI, 0x2 2b MOV RDX, 0x1 32 MOV EAX, 0x48 37 SYSCALL 39 RET .......... 100 CMP [RAX+0x8], RCX 104 JE .+60 106 XOR ECX, ECX 108 TEST CL, CL 10a JNE .+16 10c XOR EAX, EAX 10e MOV [RSP+Reg(0)+0x40], AL 112 MOV RBP, [RSP+Reg(0)+0x20] 117 ADD RSP, 0x28 11b RET 11c LEA RCX, [RDX+0x18] 120 MOV [RSP+Reg(0)], RCX 124 ADD RAX, 0x18 .......... 3fd INT 0x3 3fe INT 0x3 3ff INT 0x3 Error: x86asm decode error: truncated instruction ``` 我们也注意到最后一行有错误信息,提示“truncated instruction”,这是因为我们固定了读取指令的buffer是1024 bytes,可能有一条最后的指令没有完全读取过来,所以进行decode的时候这条指令失败了。 这里的失败是符合预期的、无害的,我们调试过程中,不会显示这么多汇编指令,只会显示断点附近的几十条指令而已,对于decode失败的buffer末尾几条指令简单忽略就可以。 现在我们已经实现了反汇编的功能,下一节,我们将通过指令patch来实现动态断点的添加、移除。 > ps: 在我们的示例程序 ``golang-debugger-lessons/5_disassemble `中提供了一个可以独立运行的程序,运行 `path-to/5_disassemble ``` 可以反汇编程序中包含的所有指令,程序也对可能遇到的错误进行了处理,包括不认识的指令、越界问题。 ### 更多相关内容 汇编指令有go、intel、gnu 3种常见风格,gnu风格的俗称at&t风格。 为了方便不同习惯的开发者能顺畅地阅读相关反汇编出来的指令,我们后续又为disass命令添加了选项 `disass -s `来指定汇编指指令的风格,如果您倾向于阅读at&t格式汇编,则可以通过 `disass -s gnu`来查看对应风格的汇编指令。 函数 `instSyntax(inst x86asm.Inst, syntax string) (string, error)`实现了对不同汇编风格的转换支持: ```go func instSyntax(inst x86asm.Inst, syntax string) (string, error) { asm := "" switch syntax { case "go": asm = x86asm.GoSyntax(inst, uint64(inst.PCRel), nil) case "gnu": asm = x86asm.GNUSyntax(inst, uint64(inst.PCRel), nil) case "intel": asm = x86asm.IntelSyntax(inst, uint64(inst.PCRel), nil) default: return "", fmt.Errorf("invalid asm syntax error") } return asm, nil } ``` 另外我们也添加了选项 `disass -n `来指定一次反汇编操作要decode的指令条数,因为调试会话中往往更关心当前待执行的指令,所以没必要一次反汇编成千上万行指令,那仅会分散调试人员的注意力而已。 您可以在项目 `hitzhangjie/godbg` 源文件 `godbg/cmd/debug/disass.go`中查看完整反汇编实现。 ### 本节小结 本节主要探讨了指令级调试中反汇编功能的实现原理与具体方法,核心内容包括:**反汇编的基本概念与实现框架选择**,通过对比Capstone、Gapstone等框架,最终选择Go官方提供的x86asm库;**ELF文件解析与指令提取**,详细说明了如何通过debug/elf包解析可执行文件,从.text段提取机器指令数据;**断点位置反汇编的特殊处理**,重点阐述了断点处指令patch机制对反汇编的影响,以及PC值调整和指令还原的关键步骤;**完整的反汇编命令实现**,提供了disassCmd的具体代码实现,支持多种汇编语法风格和指令数量控制。本节的核心难点在于理解断点处指令patch的工作原理:当断点通过0xCC指令实现时,需要正确识别PC位置并还原原始指令数据,确保反汇编结果的准确性。 通过本节的学习,读者掌握了反汇编的完整技术链路,为构建功能完整的指令级调试器提供了关键支撑。下一节将基于本节的反汇编基础,识别特定指令地址,并实现动态断点的添加与移除功能。 ### 参考文献 1. What You Need To Know About ELF, https://student.cs.uwaterloo.ca/~cs452/W18/elf/elf.html 2. dissecting go binaries, https://www.grant.pizza/blog/dissecting-go-binaries/ 3. how many x86-64 instructions are there anyway, https://stefanheule.com/blog/how-many-x86-64-instructions-are-there-anyway/ ================================================ FILE: book/6-develop-inst-debugger/6-breakpoint.md ================================================ ## 软件动态断点:添加断点 ### 实现目标:`godbg> breakpoint [address]` 添加断点 断点按照其“**生命周期**”进行分类,可以分为“**静态断点**”和“**动态断点**”。 - 静态断点的生命周期是整个程序执行期间,一般是通过执行指令 `int 0x3h`来强制插入 `0xCC`充当断点,其实现简单,在编码时就可以安插断点,但是不灵活; - 动态断点的生成、移除,是通过运行时指令patch,其生命周期是与调试活动中的操作相关的,其最大的特点就是灵活,一般是只能借由调试器来生成。 不管是静态断点还是动态断点,其原理是类似的,都是通过一字节指令 `0xCC`来实现暂停任务执行的操作,处理器执行完 `0xCC`之后会暂停当前任务执行。 > ps:我们在章节4.2中有提到 `int 0x3h`(编码后指令0xCC)是如何工作的,如果读者忘了其工作原理,可自行查阅相关章节。 断点按照“**实现方式**”的不同,也可以细分为“**软件断点**”和“**硬件断点**”。 - 硬件断点一般是借助硬件特有的调试端口来实现,如将感兴趣的指令地址写入调试端口(寄存器),当PC命中时就会触发停止tracee执行的操作,并通知tracer; - 软件断点是相对于硬件断点而言的,如果断点实现是不借助于硬件调试端口的话,一般都可以归为软件断点。 我们先只关注软件断点,并且只关注动态断点。断点的添加、移除是调试过程的基石,在我们掌握了在特定地址处添加、移除断点之后,我们可以研究下断点的应用,如step、next、continue等。 在熟练掌握了这些操作之后,我们将在后续章节结合DWARF来实现符号级断点,那时将允许你对一行语句、函数、分支控制添加、移除断点,断点的价值就进一步凸显出来了。 ### 代码实现 我们使用 `break`命令来添加断点,可以简单缩写成 `b`,使用方式如下: `break `。 locspec表示一个代码中的位置,可以是指令地址,也可以是一个源文件中的位置。locspec支持的格式,直接关系到添加断点的效率。第九章介绍符号级调试器时我们定义了一系列支持的locspec [](../9-develop-sym-debugger/2-核心调试逻辑/20-how_locspec_works.md),这里先我们先只考虑locspec为指令地址的情况。 对于指令地址,我们可以先借助反汇编操作列出程序中不同地址处的指令,有了各个指令的地址之后,我们就可以对该特定地址处的指令数据进行patch以达到添加、移除断点的目的。 现在来看下我们的实现代码: ```go package debug import ( "errors" "fmt" "strconv" "strings" "syscall" "github.com/spf13/cobra" ) var breakCmd = &cobra.Command{ Use: "break ", Short: "在源码中添加断点", Long: `在源码中添加断点,源码位置可以通过locspec格式指定。 当前支持的locspec格式,包括两种: - 指令地址 - [文件名:]行号 - [文件名:]函数名`, Aliases: []string{"b", "breakpoint"}, Annotations: map[string]string{ cmdGroupKey: cmdGroupBreakpoints, }, RunE: func(cmd *cobra.Command, args []string) error { fmt.Printf("break %s\n", strings.Join(args, " ")) if len(args) != 1 { return errors.New("参数错误") } locStr := args[0] addr, err := strconv.ParseUint(locStr, 0, 64) if err != nil { return fmt.Errorf("invalid locspec: %v", err) } // 记录地址addr处的原始1字节数据 orig := [1]byte{} n, err := syscall.PtracePeekData(TraceePID, uintptr(addr), orig[:]) if err != nil || n != 1 { return fmt.Errorf("peek text, %d bytes, error: %v", n, err) } breakpointsOrigDat[uintptr(addr)] = orig[0] // 将addr出的一字节数据覆写为0xCC n, err = syscall.PtracePokeText(TraceePID, uintptr(addr), []byte{0xCC}) if err != nil || n != 1 { return fmt.Errorf("poke text, %d bytes, error: %v", n, err) } fmt.Printf("添加断点成功\n") return nil }, } func init() { debugRootCmd.AddCommand(breakCmd) } ``` 这里的实现逻辑并不复杂,我们来看下。 首先假定用户输入的是一个指令地址,这个地址可以通过disass查看反汇编时获得。我们先尝试将这个指令地址字符串转换成uint64数值,如果失败则认为这是一个非法的地址。 如果地址有效,则尝试通过系统调用 `syscall.PtracePeekData(pid, addr, buf)`来尝试读取指令地址处开始的一字节数据,这个数据是汇编指令编码后的第1字节的数据,我们需要将其暂存起来,然后再通过 `syscall.PtracePokeData(pid, addr, buf)`写入指令 `0xCC`。 等我们准备结束调试会话时,或者显示执行 `clear`清除断点时,需要将数据这里的0xCC还原为原始数据。 ps: Linux下,PEEKDATA、PEEKTEXT、POKEDATA、POKETEXT效果是一样的, see `man 2 ptrace` : ```bash $ man 2 ptrace PTRACE_PEEKTEXT, PTRACE_PEEKDATA Read a word at the address addr in the tracee's memory, returning the word as the result of the ptrace() call. Linux does not have separate text and data address spaces, so these two requests are currently equivalent. (data is ignored; but see NOTES.) PTRACE_POKETEXT, PTRACE_POKEDATA Copy the word data to the address addr in the tracee's memory. As for PTRACE_PEEKTEXT and PTRACE_PEEKDATA, these two requests are currently equivalent. ``` ### 代码测试 下面来测试一下,首先我们启动一个测试程序,获取其pid,这个程序最好一直死循环不退出,方便我们测试。 然后我们先执行 `godbg attach `准备开始调试,调试会话启动后,我们执行disass反汇编命令查看汇编指令对应的指令地址。 ```bash godbg attach 479 process 479 attached succ process 479 stopped: true godbg> godbg> disass ............. 0x465326 MOV [RSP+Reg(0)+0x8], RSI 0x46532b MOV [RSP+Reg(0)+0x10], RBX 0x465330 CALL .-400789 0x465335 MOVZX ECX, [RSP+Reg(0)+0x18] 0x46533a MOV RAX, [RSP+Reg(0)+0x38] 0x46533f MOV RDX, [RSP+Reg(0)+0x30] ............. godbg> ``` 随机选择一条汇编指令的地址,在调试会话中输入 `break
`,我们看到提示断点添加成功了。 ```bash godbg> b 0x46532b break 0x46532b 添加断点成功 godbg> godbg> exit ``` 最后执行exit退出调试。 这里我们只展示了断点的添加逻辑,断点的移除逻辑,其实实现过程非常相似,我们将在clear命令的实现时再介绍。另外有网友可能有疑问,这里怎么没演示下添加断点后tracee暂停执行的效果呢?因为现在还没有实现执行到断点处的功能,我们会在continue操作实现后进行演示。 ps:我们添加断点功能,还停留在指令级调试功能(只实现了 `break "指令地址"` ),我们还没有实现符号级调试器在指定源码位置添加断点的操作( `break "源文件:行号"` 或者 `break "函数名"`),如果要演示在tracee在特定源码位置停下来的操作,我们得先借助DWARF调试信息获取源码位置对应的指令地址,然后在这个地址处添加断点。然后,执行 `continue` 操作让tracee运行到断点处。这部分我们会在第九章符号级调试器 [debug> continue](../9-develop-sym-debugger/2-核心调试逻辑/28-debug_continue.md) 小节进行介绍。 接下来几个小节,我们先快速介绍如何实现break(添加断点)、clear(移除断点)功能之后,我们再来看如何实现step(单步执行指令)、next(单步执行语句)、continue(执行到断点位置)等控制执行流程的调试命令,每个小节我们也会提供对应的示例进行演示。 ### 本节小结 本节主要探讨了动态断点的实现原理和具体代码实现,为调试器的核心功能奠定基础。核心要点包括:断点按生命周期分为静态断点和动态断点,按实现方式分为软件断点和硬件断点;软件断点通过将目标地址的指令替换为0xCC(int 0x3h)来实现程序暂停;实现过程包括保存原始指令字节、写入断点指令、记录断点信息三个关键步骤;使用ptrace系统调用的PEEKDATA和POKEDATA操作完成内存读写。 本节需要特别关注的是理解断点机制的本质:通过指令替换实现程序控制流的暂停,这是所有调试操作的基础。断点功能的实现为后续的step、next、continue等控制执行流程的调试命令提供了技术支撑,也是从指令级调试向符号级调试演进的重要环节。掌握了断点的添加和移除机制后,我们就能在此基础上构建更复杂的调试功能,最终实现完整的调试器系统。 ================================================ FILE: book/6-develop-inst-debugger/7-breakpoints.md ================================================ ## 软件动态断点:列出断点 ### 实现目标:`godbg> breakpoints` 列出断点 前一节中我们实现了动态断点的添加,为了能够支持移除断点,我们必须为断点提供一些描述信息,比如断点编号,这样用户可以借助断点编号来移除断点。 比如依次添加了3个断点,每个断点依次编号为 `1、2、3`,当用户希望移除断点2时,可以通过执行命令 `clear -n 2`来移除。 当然添加的断点数量多了之后,我们很难记得清楚自己添加了多少个断点,每个断点对应的指令地址是什么,添加顺序(编号)是什么,所以我们还必须提供一个列出已添加断点的功能,如执行 `breakpoints`会列出所有已添加断点。 展示样式大致如下所示,至少要显示断点编号,对应指令地址,以及源码位置。 ```bash godbg> breakpoints breakpoint[1] 0x4000001 main.go:10 breakpoint[2] 0x5000001 hello.go:20 breakpoint[3] 0x5000101 world.go:30 ``` ### 代码实现 #### 微调代码:新增断点时记录编号及位置 我们需要对前一节添加断点的部分代码进行适当修改,在添加断点时能够同时记录断点的编号、指令地址、源码位置(源码位置我们先用空串表示)。 **file: cmd/debug/break.go** ```go package debug var breakCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ... breakpoint, err := target.NewBreakpoint(addr, orig[0], "") if err != nil { return fmt.Errorf("add breakpoint error: %v", err) } breakpoints[addr] = &breakpoint ... }, } func init() { debugRootCmd.AddCommand(breakCmd) } ``` **file: target/breakpoint.go** ```go func NewBreakpoint(addr uintptr, orig byte, location string) (Breakpoint, error) { b := Breakpoint{ ID: seqNo.Add(1), Addr: addr, Orig: orig, Location: location, } return b, nil } ``` #### 新增命令:breakpoints显示断点列表 我们新增一个调试命令breakpoints,用名词复数形式来隐含表示查询所有断点的意思。实现逻辑就比较简单,我们遍历所有已添加的断点,逐个输出断点信息即可。 > `breakpoints` 操作实现比较简单,我们没有在 [hitzhangjie/golang-debug-lessons](https://github.com/hitzhangjie/golang-debug-lessons) 中单独提供示例目录,而是在 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中进行了实现,读者可以查看 godbg 的源码。 > TODO 代码示例可以优化一下, see: https://github.com/hitzhangjie/golang-debugger-book/issues/15 **file: cmd/debug/breakpoints.go** ```go package debug import ( "fmt" "sort" "godbg/target" "github.com/spf13/cobra" ) var breaksCmd = &cobra.Command{ Use: "breaks", Short: "列出所有断点", Long: "列出所有断点", Aliases: []string{"bs", "breakpoints"}, Annotations: map[string]string{ cmdGroupKey: cmdGroupBreakpoints, }, RunE: func(cmd *cobra.Command, args []string) error { bs := target.Breakpoints{} for _, b := range breakpoints { bs = append(bs, *b) } sort.Sort(bs) for _, b := range bs { fmt.Printf("breakpoint[%d] %#x %s\n", b.ID, b.Addr, b.Location) } return nil }, } func init() { debugRootCmd.AddCommand(breaksCmd) } ``` 新增断点记录在一个 `map[uintptr]*breakpoint`结构中,这里用map主要是考虑到后续插入、删除、查询的场景,有助于提升查询效率,比如重复执行 `break main.go:10`多次,首先将main.go:10转成指令地址,然后查询此map结构,可以以O(1)的时间复杂度来判断此断点是否已经存在。 上述map的key是断点的指令地址,value是断点描述信息struct,如果我们直接通过for-range来遍历map的kv然后输出其信息,那断点展示的顺序不一定就是按照断点编号。 为了能够保证断点展示的顺序能够按照编号有序展示,我们需要对断点切片Breakpoints实现 `sort.Interface{}`接口,允许其通过编号进行排序。 **file: target/breakpoint.go** ```go package target import ( "go.uber.org/atomic" ) var ( // 断点编号 seqNo = atomic.NewUint64(0) ) // Breakpoint 断点 type Breakpoint struct { ID uint64 Addr uintptr Orig byte Location string } // Breakpoints 断点切片,实现了排序接口 type Breakpoints []Breakpoint func (b Breakpoints) Len() int { return len(b) } func (b Breakpoints) Less(i, j int) bool { if b[i].ID <= b[j].ID { return true } return false } func (b Breakpoints) Swap(i, j int) { b[i], b[j] = b[j], b[i] } ``` 这样,我们既可以通过 `sort.Sort(bs)`对现有断点按照编号进行排序,然后再遍历输出断点信息即可。 基于命令行的调试器,实际调试经历来看,查看断点列表、新增断点、删除断点,相对来说也是比较频繁的。存储所有断点信息使用map和slice相比,新增、删除、查询都更方便,编码也方便 :) ### 代码测试 我们先运行一个测试程序,查看其pid,然后通过 `godbg attach `对目标进程进行调试,当调试会话准备好之后,我们通过 `disass`反汇编查看其汇编指令列表以及指令地址,然后通过 `break `来添加多个断点,并通过 `breakpoints`or `breaks`来显示已添加的断点列表。 ```bash godbg> disass ... 0x4653a6 INT 0x3 ; add breakpoint here 0x4653a7 MOV [RSP+Reg(0)+0x40], AL 0x4653ab MOV RCX, RSP ; add breakpoint here 0x4653ae INT 0x3 0x4653af AND [RAX-0x7d], CL 0x4653b2 Prefix(0xc4) Prefix(0x28) Prefix(0xc3) INT 0x3 0x4653b6 MOV EAX, [RSP+Reg(0)+0x30] 0x4653ba ADD RAX, 0x8 0x4653be INT 0x3 0x4653bf MOV [RSP+Reg(0)], EAX 0x4653c2 REX.W Op(0) ... godbg> b 0x4653a6 break 0x4653a6 添加断点成功 godbg> b 0x4653ab break 0x4653ab 添加断点成功 godbg> breakpoints breakpoint[1] 0x4653a6 breakpoint[2] 0x4653ab godbg> ``` 我们可以看到添加了断点之后,breakpoints命令正常显示了断点列表。现在我们只显示了断点编号、断点指令地址,当我们实现符号级调试器时,我们还会显示出断点源码位置 file:lineno。 ```bash godbg> breakpoints breakpoint[1] 0x4653a6 breakpoint[2] 0x4653ab ``` 这里的编号1、2将用来作为断点标识用以移除断点,我们将在clear命令中描述这点。 ### 本节小结 本节主要探讨了动态断点中"列出断点"功能的实现,包括断点编号管理、sortable断点信息存储结构设计、O(1)时间复杂度查询指定编号的断点。本节内容完善了动态断点的管理功能,为调试器提供了完整的断点查看能力,与前后章节的断点添加、移除功能形成完整的断点管理闭环。下一节将基于本节建立的断点编号机制,实现断点的移除功能。 ================================================ FILE: book/6-develop-inst-debugger/8-clear.md ================================================ ## 软件动态断点:移除断点 ### 设计目标:`godbg> clear -n [bpNo.]` 移除断点 前面介绍了如何添加断点、显示断点列表,现在我们来看看如何移除断点。 移除断点与新增断点,都是需要借助ptrace来实现。回想下新增断点首先通过PTRACEPEEKDATA/PTRACEPOKEDATA来实现对指令数据的备份、覆写,移除断点的逻辑有点相反,先将原来备份的指令数据覆写回断点对应的指令地址处,然后,从已添加断点集合中移除即可。 > ps: 在Linux下PTRACE_PEEKTEXT/PTRACE_PEEKDATA,以及PTRACE_POKETEXT/PTRACE_POKEDATA并没有什么不同,所以执行ptrace操作的时候,ptrace request可以任选一个。 > > 为了可读性,读写指令时倾向于PTRACE_PEEKTEXT/PTRACE_POKETEXT,读写数据时则倾向于PTRACE_PEEKDATA/PTRACE_POKEDATA。 ### 代码实现 首先解析断点编号参数 `-n `,并从已添加断点集合中查询,是否有编号为n的断点存在,如果没有则 `` 为无效参数。 如果断点确实存在,则执行ptrace(PTRACE_POKEDATA,...)将原来备份的1字节指令数据覆写回原指令地址,即消除了断点。然后,再从已添加断点集合中删除这个断点。 clear 操作实现比较简单,在 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中进行了实现,读者可以查看 godbg 的源码。但是我们也强调过了,上述repo提供的是一个功能相对完备的调试器,代码量会比较大。因此我们也在 [hitzhangjie/golang-debugger-lessons](https://github.com/hitzhangjie/golang-debugger-lessons))/8_clear 提供了测试用例,测试用例中演示了break、breakpoints、continue、clear这几个断点相关操作。 ```go package debug import ( "errors" "fmt" "strings" "syscall" "godbg/target" "github.com/spf13/cobra" ) var clearCmd = &cobra.Command{ Use: "clear ", Short: "清除指定编号的断点", Long: `清除指定编号的断点`, Annotations: map[string]string{ cmdGroupKey: cmdGroupBreakpoints, }, RunE: func(cmd *cobra.Command, args []string) error { fmt.Printf("clear %s\n", strings.Join(args, " ")) id, err := cmd.Flags().GetUint64("n") if err != nil { return err } // 查找断点 var brk *target.Breakpoint for _, b := range breakpoints { if b.ID != id { continue } brk = b break } if brk == nil { return errors.New("断点不存在") } // 移除断点 n, err := syscall.PtracePokeData(TraceePID, brk.Addr, []byte{brk.Orig}) if err != nil || n != 1 { return fmt.Errorf("移除断点失败: %v", err) } delete(breakpoints, brk.Addr) ... fmt.Println("移除断点成功") return nil }, } func init() { debugRootCmd.AddCommand(clearCmd) clearCmd.Flags().Uint64P("n", "n", 1, "断点编号") } ``` ### 代码测试 首先运行一个待调试程序,获取其pid,然后通过 `godbg attach `调试目标进程,首先通过命令 `disass`显示汇编指令列表,然后执行 `b `命令添加几个断点。 ```bash godbg> b 0x4653af break 0x4653af 添加断点成功 godbg> b 0x4653b6 break 0x4653b6 添加断点成功 godbg> b 0x4653c2 break 0x4653c2 添加断点成功 ``` 这里我们执行了3次断点添加操作,`breakpoints`可以看到添加的断点列表: ```bash godbg> breakpoints breakpoint[1] 0x4653af breakpoint[2] 0x4653b6 breakpoint[3] 0x4653c2 ``` 然后我们执行 `clear -n 2`移除第2个断点: ```bash godbg> clear -n 2 clear 移除断点成功 ``` 接下来再次执行 `breakpoints`查看剩余的断点: ```bash godbg> bs breakpoint[1] 0x4653af breakpoint[3] 0x4653c2 ``` 现在断点2已经被移除了,我们的添加、移除断点的功能是正常的。 ### 思考:仅还原指令数据就可以吗 大家考虑这么一种特殊情况:存在某个线程线程已经停在了要删除的断点处,换言之,它已经执行了被patched指令的第1字节0xCC,当前PC指向第2字节,如果我们对这个线程的PC不做回退,那么当我们执行continue恢复其执行时,CPU取指令、指令译码将从上述还原后的完整指令的第2字节开始,而不是第1字节。这样,显然CPU指令译码时会出错。 所以上述clear命令的实现是不完备的,需要补充查找受影响的线程列表,以及rewind线程PC的逻辑。 godbg中clearCmd的后续实现代码如下,您可以查看 [hitzhangjie/godbg]: ```go var clearCmd = &cobra.Command{ Use: "clear ", Short: "清除指定编号的断点", Long: `清除指定编号的断点`, ... RunE: func(cmd *cobra.Command, args []string) error { //fmt.Printf("clear %s\n", strings.Join(args, " ")) id, err := cmd.Flags().GetUint64("n") if err != nil { return err } // 查找断点 var brk *target.Breakpoint for _, b := range target.DBPProcess.Breakpoints { if b.ID != id { continue } brk = b break } if brk == nil { return errors.New("断点不存在") } // 移除断点 _, err = target.DBPProcess.ClearBreakpoint(brk.Addr) if err != nil { return err } fmt.Println("移除断点成功") return nil }, } // ClearBreakpoint 移除指定地址处断点,并rewind受影响的thread func (p *DebuggedProcess) ClearBreakpoint(addr uintptr) (*Breakpoint, error) { brk, err := p.RestoreInstruction(addr) if err != nil { return nil, err } // 是否有线程需要rewind pc bpStoppedThreads, err := p.ThreadStoppedAtBreakpoint() if err != nil { return nil, fmt.Errorf("检查线程停在断点处失败: %v", err) } for tid, bpAddr := range bpStoppedThreads { if bpAddr != brk.Addr { continue } regs, err := p.ReadRegister(tid) if err != nil { return nil, fmt.Errorf("读取寄存器失败: %v", err) } regs.SetPC(regs.PC() - 1) if err = p.WriteRegister(tid, regs); err != nil { return nil, fmt.Errorf("写入寄存器失败: %v", err) } } return brk, nil } // ThreadStoppedAtBreakpoint 检查所有线程是否停在断点处 func (p *DebuggedProcess) ThreadStoppedAtBreakpoint() (map[int]uintptr, error) { threadStoppedAtBP := make(map[int]uintptr) if len(p.Threads) == 0 { return threadStoppedAtBP, nil } for tid, thread := range p.Threads { regs, err := p.ReadRegister(thread.Tid) if err != nil { // 线程可能已经退出,跳过 if err == syscall.ESRCH { fmt.Fprintf(os.Stderr, "warn: thread %d exited\n", tid) continue } return nil, fmt.Errorf("read register for thread %d: %v", tid, err) } // 检查PC-1位置是否有断点(因为断点指令已经执行) pc := regs.PC() if bp, exists := p.Breakpoints[uintptr(pc-1)]; exists { threadStoppedAtBP[tid] = bp.Addr } } return threadStoppedAtBP, nil } // 移除断点,还原指令数据+从断点列表中移除 func (p *DebuggedProcess) RestoreInstruction(addr uintptr) (*Breakpoint, error) { brk, ok := p.Breakpoints[addr] if !ok { return nil, ErrBreakpointNotExisted } // 移除断点 pid := p.Process.Pid err := p.ExecPtrace(func() error { n, err := syscall.PtracePokeData(pid, brk.Addr, []byte{brk.Orig}) if err != nil || n != 1 { return fmt.Errorf("ptrace poke data err: %v", err) } delete(p.Breakpoints, brk.Addr) return nil }) if err != nil { return nil, err } return brk, nil } ``` ### 本节小结 本节主要探讨了调试器中动态断点的移除功能实现,核心内容包括断点移除与添加的对称性、ptrace系统调用的反向操作,以及删除断点时的断点编号验证、断点查找、指令恢复和断点集合清理步骤。ptrace操作PTRACE_PEEKTEXT/PTRACE_POKETEXT用于指令操作,PTRACE_PEEKDATA/PTRACE_POKEDATA用于数据操作,但实际功能相同。 本节内容完善了调试器断点管理的核心功能,与前面的断点添加、断点列表显示功能共同构成了完整的断点操作体系,为读者理解调试器内部机制提供了重要的实践基础。通过本节的学习,读者可以掌握断点移除的底层实现原理,为后续学习更复杂的调试器功能(如条件断点、断点修改等)奠定了技术基础。 ================================================ FILE: book/6-develop-inst-debugger/80-aslr.md ================================================ ## 扩展阅读:地址空间布局随机化 ### ASLR是什么 ![ASLR](assets/aslr.png) ASLR是一种操作系统级别的安全技术,主要作用是通过随机化程序在内存中的加载位置来增加攻击者预测目标地址、利用软件漏洞进行恶意操作的难度。其核心机制包括动态随机分配进程地址空间中各个部分的位置,例如基址、库文件、堆和栈等。 ### 它有什么价值 ASLR的核心价值在于提高系统的安全性,具体体现在以下几个方面: 1. **增强系统防御能力** :通过引入不可预测性,使得攻击者无法准确地预知内存中的关键函数或数据的位置。这对于依赖于固定地址的缓冲区溢出等漏洞利用方式而言尤其重要。 2. **缓解特定类型的攻击** :ASLR能够有效地对抗诸如堆栈喷射(stack spray)、返回导向编程(ROP, Return-Oriented Programming)及直接控制程序执行流的其他技术,这些通常需要确切了解目标地址的信息。 3. **提高整体安全性框架的有效性** :与数据执行保护(DEP)等其他安全机制结合使用时,ASLR可以显著提升整个系统的防护能力。例如,在DEP阻止代码在堆栈或堆内存中执行的情况下,如果攻击者仍试图通过缓冲区溢出来控制程序的流程,则必须知道某些特定位置的确切地址,而ASLR的存在则使这种情况变得更加困难。 ### 它的实现原理 以Linux系统为例,下列ASLR内核参数配置项及其含义: ```bash $ cat /proc/sys/kernel/randomize_va_space 2 ``` - 0: 禁用ASLR (不安全,不推荐禁用). - 1: 有限保守的随机化支持 (仅支持 stack、heap、libraries); - 2: 完整的随机化支持 (包括 stack、heap、libraries 以及 executable). 操作系统上述内核配置项是2,意味着内核开启了完整的随机化支持,但是对于可执行程序的随机化支持,要求可执行程序构建必须开启了PIE模式。 随机化是如何实现的: - Stack: The stack pointer (RSP or ESP) is initialized to a random offset within a predefined range. - Heap: The starting address for dynamic memory allocation (brk or mmap) is randomized. - Shared Libraries: The loader uses mmap to load libraries at random addresses. - Executable: If compiled with PIE, the code’s base address is also randomized. executable地址是如何被随机化的呢? 这里是指代码段(text segment)的加载地址被随机化: - 如果可执行程序禁用了PIE模式,那么代码段的加载地址 `VirtAddress` 是固定的 - 如果可执行程序启用了PIE模式,那么 `VirtAddress` 只是一个偏移量,加载器会选择一个随机地址, 再加上这个偏移量,最终计算出实际的加载地址。 ### 对调试的影响 我们为什么要介绍ALSR呢?因为它对调试过程还是有影响的。`go build` 构建出来的可执行程序,每次启动后同一个函数的指令地址是固定不变的,但是同一个栈变量的地址在每次启动后却是变化的 …… 我们知道有些读者会对此产生困惑,所以要介绍下。 造成这个问题的原因,现在Linux发行版一般都是默认打开完全随机化支持的,意味着即使go程序编译时没有开启PIE模式,栈、堆、共享库地址还是会被随机化处理的,所以我们观察到了多次启动后同一个栈变量地址不同。而对可执行程序代码的随机化则要求必须要开启PIE模式构建才能支持,所以我们观察到了多次启动后代码段地址是固定的。如果我们使用 `go build -buildmode=pie` 之后,重新测试就会发现代码段的地址也被随机化了。 有些调试器支持将一些调试过程记录下来作为脚本的形式,下次重启调试器时可以直接加载,如果上次调试时脚本里记录了一些指令地址、变量地址,那么下次重启被调试进程调试时ASLR的影响会导致地址不再有效。 OK,我们先笼统介绍到这里,大家知道ASLR对调试过程可能产生的影响即可。如果想了解更多可以看下相关资料。 > ps: 您可以通过pmap, /proc//maps来查看进程内的代码段、栈、堆、共享库在进程地址空间内的分布和位置,您也可以借助调试器能力来印证。 ### 参考文献 - [https://en.wikipedia.org/wiki/Address_space_layout_randomization](https://en.wikipedia.org/wiki/Address_space_layout_randomization) - [Early Security Stories — ASLR](https://medium.com/@johnlatwc/early-security-stories-aslr-4c6bafe0dda1) - [Demystifying ASLR: Understanding, Exploiting, and Defending Against Memory Randomization](https://securitymaven.medium.com/demystifying-aslr-understanding-exploiting-and-defending-against-memory-randomization-4dd8fe648345) - [ASLR: Address Space Layout Randomization](https://medium.com/@syedishrarali/aslr-address-space-layout-randomization-eb94203a0e7d) - [Introduction to x64 Linux Binary Exploitation (Part 5)- ASLR](https://valsamaras.medium.com/introduction-to-x64-linux-binary-exploitation-part-5-aslr-394d0dc8e4fb) ================================================ FILE: book/6-develop-inst-debugger/9-clearall.md ================================================ ## 软件动态断点:清空断点 ### 实现目标:`godbg> clearall` 清空断点 `clearall`命令的功能是为了快速移除所有断点,而不用通过 `clear -n `逐个删除断点,适合添加了很多断点想快速清理的场景。 ### 代码实现 `clearall`的实现逻辑,和 `clear`逻辑差不多,相比较之下处理逻辑更简单点。 > clearall 操作实现比较简单,我们没有在 [hitzhangjie/golang-debug-lessons](https://github.com/hitzhangjie/golang-debug-lessons) 中单独提供示例目录,而是在 [hitzhangjie/godbg](https://github.com/hitzhangjie/godbg) 中进行了实现,读者可以查看 godbg 的源码。 > > TODO 代码示例可以优化一下, see: [issue #15](https://github.com/hitzhangjie/golang-debugger-book/issues/15) **file: cmd/debug/clearall.go** ```go package debug import ( "fmt" "syscall" "godbg/target" "github.com/spf13/cobra" ) var clearallCmd = &cobra.Command{ Use: "clearall ", Short: "清除所有的断点", Long: `清除所有的断点`, Annotations: map[string]string{ cmdGroupKey: cmdGroupBreakpoints, }, RunE: func(cmd *cobra.Command, args []string) error { fmt.Println("clearall") for _, brk := range breakpoints { n, err := syscall.PtracePokeData(TraceePID, brk.Addr, []byte{brk.Orig}) if err != nil || n != 1 { return fmt.Errorf("清空断点失败: %v", err) } } breakpoints = map[uintptr]*target.Breakpoint{} ... fmt.Println("清空断点成功") return nil }, } func init() { debugRootCmd.AddCommand(clearallCmd) } ``` ### 代码测试 首先运行一个待调试程序,获取其pid,然后通过 `godbg attach `调试目标进程,首先通过命令 `disass`显示汇编指令列表,然后执行 `b `命令添加几个断点。 ```bash godbg> b 0x4653af break 0x4653af 添加断点成功 godbg> b 0x4653b6 break 0x4653b6 添加断点成功 godbg> b 0x4653c2 break 0x4653c2 添加断点成功 ``` 这里我们执行了3次断点添加操作,`breakpoints`可以看到添加的断点列表: ```bash godbg> breakpoints breakpoint[1] 0x4653af breakpoint[2] 0x4653b6 breakpoint[3] 0x4653c2 ``` 然后我们执行 `clearall`清空所有断点: ```bash godbg> clearall clearall 清空断点成功 ``` 接下来再次执行 `breakpoints`查看剩余的断点: ```bash godbg> bs godbg> ``` 现在已经没有剩余断点了,我们的添加、清空断点的功能是正常的。 OK ,截止到现在,我们已经实现了添加断点、列出断点、删除指定断点、清空断点的功能,但是我们还没有演示过断点的效果(执行到断点处停下来)。接下来我们就将实现step(执行1条指令)、continue(运行到断点处)操作。 ### 思考:仅还原指令数据就可以吗? 与clear命令类似,清理断点只还原指令数据仅仅算完成了第一步,还需要将停在这些断点处的线程的pc--。clearall由于是移除所有断点,受影响的线程数量比clear移除单个断点影响的线程数量要更多,所以更要注意处理。否则很可能clearall执行一次,后续就没法进行调试了,因为各个线程执行时的机器指令CPU已经无法正常译码了。 godbg中修改后的clearall命令实现,如下所示,您也可以查看 [hitzhangjie/godbg]: ```go var clearallCmd = &cobra.Command{ Use: "clearall", Short: "清除所有的断点", Long: `清除所有的断点`, Annotations: map[string]string{ cmdGroupAnnotation: cmdGroupBreakpoints, }, RunE: func(cmd *cobra.Command, args []string) error { //fmt.Println("clearall") if err := target.DBPProcess.ClearAll(); err != nil { return fmt.Errorf("清除断点失败: %v", err) } fmt.Println("清空断点成功") return nil }, } // ClearAll 删除所有已添加的断点 func (p *DebuggedProcess) ClearAll() error { // 首先检查所有线程是否停在断点处 stopped, err := p.ThreadStoppedAtBreakpoint() if err != nil { return fmt.Errorf("check thread breakpoints error: %v", err) } for _, bp := range p.Breakpoints { if _, err := p.RestoreInstruction(bp.Addr); err != nil { return fmt.Errorf("clear breakpoint at %#x error: %v", bp.Addr, err) } } // 如果有线程停在断点处,需要先处理这些线程 // 回退所有停在断点的线程的PC for tid := range stopped { regs, err := p.ReadRegister(tid) if err != nil { return fmt.Errorf("read register for thread %d: %v", tid, err) } // 回退PC到断点指令之前 regs.SetPC(regs.PC() - 1) if err = p.WriteRegister(tid, regs); err != nil { return fmt.Errorf("write register for thread %d: %v", tid, err) } } return nil } ``` ### 思考:如果tracer退出前不清理断点? 思考一个问题,tracer添加、移除断点都是通过ptrace系统调用对进程指令进行patch,那么如果tracer退出前不主动清除过去添加过的断点会怎样?我们将在[调试器退出前的断点清理机制](./10-clearall-atexit.md)这一节中进行详细介绍。 ### 本节小结 本节主要探讨了调试器中 `clearall`命令的实现,核心内容包括:**批量断点清理机制**;**ptrace系统调用的断点恢复操作**。本节内容为读者学习后续的断点执行控制(step、continue)打下了基础。 本节核心要点包括: - `clearall`命令通过遍历全局断点映射,使用 `PtracePokeData`恢复原始指令字节,实现所有断点的批量清理 - 相比 `clear`命令的单个删除,`clearall`提供了更高效的断点管理方式,适合调试过程中需要快速重置断点状态的场景 掌握了断点的添加、删除和清理后,接下来我们将学习如何让程序在断点处停下来,实现真正的断点调试功能。 ================================================ FILE: book/6-develop-inst-debugger/99-more.md ================================================ ## 更多关于指令级调试的内容 ### 简单回顾 本章从零开始基于Go语言构建了一个指令级调试器,并通过详细的说明和示例进行了解释,让读者能够亲手实践并理解其中的每个细节。我们不仅提供了可以直接编译运行的测试代码,还精心设计了每一步测试操作和预期结果,力求使每位学习者都能够轻松上手并在实践中获得深刻的体验。 在整个开发过程中,我们深入探讨了Go语言运行时环境与操作系统内核层面的工作,帮助读者建立了更全面的调试知识框架。这不仅仅是一次技术上的探索之旅,相信也让读者减少了对Go运行时、Linux内核的畏惧,增加了进一步学习钻研的兴趣。这种经历对于希望成为优秀软件工程师的人而言非常重要。 > ps: 没有开源大佬们的贡献,我也没啥好学习、好总结、好分享的。特别佩服 [derekparker](https://github.com/derekparker) 和 [arzilli](https://github.com/aarzilli) 对Go语言调试器 `go-delve/delve` 的贡献 👍 ### 名品鉴赏 本书用一章来介绍了指令级调试器的常见功能以及相关技术细节,但离高效实用还有距离。本书初衷并不是为了实现一款更高效的调试器,而是一款强大的调试器实在令人着迷,它好比让你拥有了上帝之眼和造物主之手,你可以观察世界如何运行,也可以用指尖轻触来影响它们的运行轨迹。 这背后用来支撑实现它们的技术细节怎能不让人着迷?所以我想把这些分享出来,进而让大家产生这种认识或者共识的基础上,继续去探索、去利用好这些强大的工具,为己所用。 支持指令级调试的调试器,下面这些比较知名: 1. **GDB(GNU调试器)** - 在汇编模式下使用时,它可以提供完整的指令级调试功能。GDB支持多种处理器架构,并可以与各种前端界面集成。 2. **WinDbg** - 微软开发的强大低级调试器,广泛用于Windows系统调试和驱动开发。它支持内核模式调试和用户模式调试,可以分析转储文件和实时系统。 3. **OllyDbg** - 广泛用于Windows二进制分析的工具,具有友好的用户界面和丰富的插件生态系统。特别适合逆向工程和恶意软件分析。 4. **IDA Pro** - 专业级反汇编器和调试器,提供交互式调试功能,支持多平台和多处理器架构,在安全研究和逆向工程领域非常流行。 5. **LLDB** - LLVM项目的一部分,提供与GDB相似的功能,但架构更现代,特别适合调试使用LLVM编译的程序。 6. **x64dbg/x32dbg** - 开源Windows调试器,拥有直观的用户界面和强大的功能集,在安全研究人员和逆向工程师中非常受欢迎。 7. **Radare2/Cutter** - 开源逆向工程框架,提供强大的命令行工具和图形界面(Cutter),支持多种文件格式和架构。 8. **Ghidra** - 美国国家安全局发布的逆向工程工具,具有强大的分析能力和插件系统,包含调试功能。 ### 各自优势 **指令级调试的优势**: 上面这些知名的反汇编工具、调试器、软件逆向工具,如同所介绍的那样,确实功能强大,建议读者能花时间了解一下。而,单纯就指令级调试这部分而言,我觉得它们的优势主要体现在下面这些方面,这是符号级调试器设计实现时可能不那么重点关注的(比如dlv支持disass但不支持radare2那样的callgraph)。 * **编译优化问题** :当编译器优化导致意外行为,需要查看实际生成的指令。 * **硬件相关问题** :调试与硬件直接交互的代码,如驱动程序、嵌入式系统。 * **没有源码的软件分析** :逆向工程商业软件或遗留系统。 * **复杂崩溃分析** :调查从源代码中不明显的复杂崩溃路径。 * **安全漏洞研究** :分析和开发漏洞利用或防御机制。 **符号级调试的优势**: 指令级调试器固然强大,但是也要分什么场景、什么开发者来用,对于大多数使用高级语言编写业务逻辑的开发者而言,一款好用的符号级调试器可能更实用。因此这里我们需要强调下指令级调试器、符号级调试器各自的优势,它们并不是割裂的,有些符号级调试器也支持常见的指令级调试。 * **更高抽象层次** :使用变量、函数和数据结构而不是寄存器和内存地址,使调试过程更直观。 * **更快的调试流程** :对于熟悉源代码的开发人员来说更加直观,可以更快地定位问题。 * **语言特性支持** :理解特定编程语言的构造,如类、异常处理、泛型等。 * **生产力提升** :在自己编写的代码中识别问题更快捷,大多数日常开发调试任务更高效。 * **团队协作** :更容易与团队成员共享和讨论发现,因为调试是在源代码级别进行的。 ### 本节小结 指令级调试虽然学习曲线较陡,但它提供了源码级调试无法比拟的深度和控制力,对于需要深入理解系统内部工作原理的开发人员和研究人员来说是不可或缺的工具。 > ps: 这里建议读者掌握radare2的使用,它也有图形化界面支持的版本Cutter。以前我在macOS平台下经常用的是Hopper,机缘巧合了解到了radare2,也推荐给大家。 OK,关于指令级调试的部分,我们就先介绍到这里,我们接下来将继续解开符号级调试的神秘面纱。指令级调试器层面解决的是对tracee的控制层面的问题(执行、暂停、寄存器访问、内存访问等),符号级调试器层面解决的是如何建立起源码和进程映像之间的联系,如源码和指令之间的关系,变量值、数据类型和内存数据间的关系,等等。符号级调试器让调试变得更加简单高效,尤其是你不需要关心更底层细节的时候。 符号级调试器的支持是一个更加庞大的工程,我们将学习调试信息如何建立起对不同编程语言、程序构造的支持(DWARF),还将了解这些信息如何生成(编译器、链接器)以及被利用(调试器),我们还将了解如何根据调试信息指导来建立对源码、进程内存数据的理解。 让我们开始吧 ~ ================================================ FILE: book/6-develop-inst-debugger/README.md ================================================ ## 指令级调试器开发 本章开始进入指令级调试器开发,我们将一步步实现指令级调试相关操作。 ### 指令级调试 VS. 符号级调试 指令级调试是相对符号级调试而言的。它只关心机器指令级别的调试,不依赖调试符号、源程序信息。缺少了调试符号信息,会让调试变得有些困难,难以理解调试代码的含义。 但是指令级调试技术是符号级调试技术的基石,可以说符号级调试相关的操作是在指令级调试基础上的完善。大家在软件开发过程中接触到的大多数调试器,是符号级调试器,如gdb、lldb、dlv等,但是它们也具备指令级调试能力。当然也有一些专门的指令级调试器,如radare2、IDA Pro、OllyDbg、Hopper等。 ### 指令级调试器功能一览 指令级调试技术,在软件逆向工程中的应用是非常广泛的。当然这里要求调试器具备更加强大的能力,绝不仅仅是只支持step逐指令执行、读写内存、读写寄存器这么简单,下面就以作者经常使用的radare2为例演示下其有多强大。 以如下程序main.go为例: ```go package main import "fmt" func main() { fmt.Println("vim-go") } ``` 执行`go build -o main main.go`编译完成,然后执行`radare2 main`: ```bash $ go build -o main main.go $ $ r2 main [0x0105cba0]> s sym._main.main ; 注意先定位到函数main.main [0x0109ce80]> af ; 对当前函数进行分析 [0x0109ce80]> pdf ; 反汇编当前函数并打印 ; CODE XREF from sym._main.main @ 0x109cf04 ┌ 137: sym._main.main (); │ ; var int64_t var_50h @ rsp+0x8 │ ; var int64_t var_48h @ rsp+0x10 │ ; var int64_t var_40h @ rsp+0x18 │ ; var int64_t var_38h @ rsp+0x20 │ ; var int64_t var_18h @ rsp+0x40 │ ; var int64_t var_10h @ rsp+0x48 │ ; var int64_t var_8h @ rsp+0x50 │ ┌─> 0x0109ce80 65488b0c2530. mov rcx, qword gs:[0x30] │ ╎ 0x0109ce89 483b6110 cmp rsp, qword [rcx + 0x10] │ ┌──< 0x0109ce8d 7670 jbe 0x109ceff │ │╎ 0x0109ce8f 4883ec58 sub rsp, 0x58 │ │╎ 0x0109ce93 48896c2450 mov qword [var_8h], rbp │ │╎ 0x0109ce98 488d6c2450 lea rbp, [var_8h] │ │╎ 0x0109ce9d 0f57c0 xorps xmm0, xmm0 │ │╎ 0x0109cea0 0f11442440 movups xmmword [var_18h], xmm0 │ │╎ 0x0109cea5 488d0554e200. lea rax, [0x010ab100] │ │╎ 0x0109ceac 4889442440 mov qword [var_18h], rax │ │╎ 0x0109ceb1 488d05a8b804. lea rax, [0x010e8760] │ │╎ 0x0109ceb8 4889442448 mov qword [var_10h], rax │ │╎ 0x0109cebd 488b0594e10d. mov rax, qword [sym._os.Stdout] ; [0x117b058:8]=0 │ │╎ 0x0109cec4 488d0d35d304. lea rcx, sym._go.itab._os.File_io.Writer ; 0x10ea200 ; "`>\v\x01" │ │╎ 0x0109cecb 48890c24 mov qword [rsp], rcx │ │╎ 0x0109cecf 4889442408 mov qword [var_50h], rax │ │╎ 0x0109ced4 488d442440 lea rax, [var_18h] │ │╎ 0x0109ced9 4889442410 mov qword [var_48h], rax │ │╎ 0x0109cede 48c744241801. mov qword [var_40h], 1 │ │╎ 0x0109cee7 48c744242001. mov qword [var_38h], 1 │ │╎ 0x0109cef0 e87b99ffff call sym._fmt.Fprintln │ │╎ 0x0109cef5 488b6c2450 mov rbp, qword [var_8h] │ │╎ 0x0109cefa 4883c458 add rsp, 0x58 │ │╎ 0x0109cefe c3 ret │ └──> 0x0109ceff e87cc4fbff call sym._runtime.morestack_noctxt └ └─< 0x0109cf04 e977ffffff jmp sym._main.main [0x0109ce80]> ``` 我们在radare2调试会话里面执行了3个命令: - s sym._main.main,定位到main.main函数; - af,对当前函数进行分析; - pdf,对当前函数进行反汇编并打印出来; 大家可以看到,与普通符号级调试器disass命令不同的是,radare2不仅展示了汇编信息,还将函数调用关系的起止点通过箭头的形式给标识了出来。对于调试时只能查看指令列表,而看不到符号级函数调用的情景来说,这个功能就非常便利了。 甚至可以执行命令`vV`将汇编指令转换成调用图(callgraph)的形式: ![radare2 callgraph](assets/radare2-callgraph.png) 是不是有点神奇,是不是很强大?当读者理解了像ABI、function prologue、function epilogue之后就明白如何实现此类功能了。 radare2的功能之强大远不只是这些,从其支持的命令及选项可见一斑,其学习曲线也异常陡峭,逆向工程师、安全从业人员、对二进制分析感兴趣的人都对其青睐有加。 ```bash [0x0109ce80]> ? Usage: [.][times][cmd][~grep][@[@iter]addr!size][|>pipe] ; ... Append '?' to any char command to get detailed help Prefix with number to repeat command N times (f.ex: 3x) | %var=value alias for 'env' command | *[?] off[=[0x]value] pointer read/write data/values (see ?v, wx, wv) | (macro arg0 arg1) manage scripting macros | .[?] [-|(m)|f|!sh|cmd] Define macro or load r2, cparse or rlang file | _[?] Print last output | =[?] [cmd] send/listen for remote commands (rap://, raps://, udp://, http://, ) | <[...] push escaped string into the RCons.readChar buffer | /[?] search for bytes, regexps, patterns, .. | ![?] [cmd] run given command as in system(3) | #[?] !lang [..] Hashbang to run an rlang script | a[?] analysis commands | b[?] display or change the block size | c[?] [arg] compare block with given data | C[?] code metadata (comments, format, hints, ..) | d[?] debugger commands | e[?] [a[=b]] list/get/set config evaluable vars | f[?] [name][sz][at] add flag at current address | g[?] [arg] generate shellcodes with r_egg | i[?] [file] get info about opened file from r_bin | k[?] [sdb-query] run sdb-query. see k? for help, 'k *', 'k **' ... | l [filepattern] list files and directories | L[?] [-] [plugin] list, unload load r2 plugins | m[?] mountpoints commands | o[?] [file] ([offset]) open file at optional address | p[?] [len] print current block with format and length | P[?] project management utilities | q[?] [ret] quit program with a return value | r[?] [len] resize file | s[?] [addr] seek to address (also for '0x', '0x1' == 's 0x1') | t[?] types, noreturn, signatures, C parser and more | T[?] [-] [num|msg] Text log utility (used to chat, sync, log, ...) | u[?] uname/undo seek/write | v visual mode (v! = panels, vv = fcnview, vV = fcngraph, vVV = callgraph) | w[?] [str] multiple write operations | x[?] [len] alias for 'px' (print hexadecimal) | y[?] [len] [[[@]addr Yank/paste bytes from/to memory | z[?] zignatures management | ?[??][expr] Help or evaluate math expression | ?$? show available '$' variables and aliases | ?@? misc help for '@' (seek), '~' (grep) (see ~??) | ?>? output redirection | ?|? help for '|' (pipe) [0x0109ce80]> ``` 如果读者进一步了解下rafare2的详细功能,它功能之强大一定会让你感到惊叹。 ps: 如果读者想了解radare2的使用,可以先看下我之前写过的一偏实践文章:[monkey patching in golang](https://www.hitzhangjie.pro/blog/2020-08-23-monkey_patching_in_go/),描述了指令patch技术在golang mock测试中 的应用,以及如何借助radare2来演示指令patching的过程。 ### 以不变应万变的神兵利器 虽然指令级调试器在使用体验上可能没有符号级调试器那样直观友好,比如无法直接显示变量名、源码行号等高级信息,但它的强大之处恰恰在于“底层通用性”和“无限可能”。指令级调试器并不依赖于任何特定的编程语言或编译器生成的符号信息,无论是Assembly、C、C++、Go、Rust,还是其他任何能够编译为机器码的语言,只要你了解目标平台的指令集和操作系统机制,就可以用同样的方式进行调试和分析。 对于真正精通底层原理、熟悉指令集和操作系统的开发者来说,指令级调试器就是一把无往不利的“神兵利器”。它能够帮助你跨越语言和平台的壁垒,深入理解和掌控程序的每一条指令执行过程。正如掌握了强大通用命令行工具后,可以在不同平台上游刃有余地解决问题一样 (嗯...比如编辑器Vim),精通指令级调试器也能让你在各种环境下都能以不变应万变,解决最棘手的底层问题。 总之,指令级调试器虽然门槛较高,但一旦掌握,其灵活性和强大能力远超想象,是每一个追求极致掌控力的开发者不可或缺的工具。 大名鼎鼎的radare2第一个版本是2011年10月12日发布的v0.8.6,现在已经是2025年8月26日,14年过去了,而它的前身radare从github上可查的第一次commit,更是有18年的历史了。18年的历史,18年的不断淬炼,18年的智慧沉淀与经验传承,👍 ### 本节小结 本章主要介绍指令级调试的相关功能,旨在帮助读者理解底层调试的实现原理。我们以学习和分享为出发点,聚焦于核心机制的讲解,而非追求工程上功能的全面覆盖。如果时间篇幅允许,也会适当与其他指令级调试器进行对比,探讨不同特性的实现方式。让我们一起开始本章知识的学习吧。 ================================================ FILE: book/7-headto-sym-debugger/1-how-go-build-works.md ================================================ ## How "go build" works ### 基础知识 `go build` 这个命令用于完成go程序构建,只要用过go的相信都不陌生,但大家是否有仔细去看过这条命令到底涉及到了哪些操作呢?更甚至有没有仔细看过 `go help build` 都支持哪些选项?和 `go tool compile` 又有什么区别? OK,我们这里并不是故意挑事,如果运行的一切顺利,有谁会多此一举非得看看它内部是怎么工作的呢,毕竟大家都是学习过编译原理的,对不对?对。但是,我恰恰就遇到过几次事情,强迫我把go源码中的工具链部分研究了下。 故事起因是因为 `go test` 做了些额外生成main函数桩代码、flags解析的工作,当时go1.13调整了一个flags解析顺序的代码,导致我编写的 [微服务框架trpc](https://github.com/Tencent/trpc) 配套的效率工具无法正常工作了。于是我就想知道 `go test` 到底是如何工作的,进而了解到 `go test -v -x -work` 和 `go build -v -x -work` 这几个可以展示编译构建过程、保留构建临时目录及产物的控制选项。这样一点点入手逐渐了解了 `go build` 和 `go test` 的详细执行过程。 这部分内容如果您感兴趣可以参考我的博客或者自己阅读go源码。 - [go源码剖析 - go命令/go build](https://www.hitzhangjie.pro/blog/2020-09-28-go%E6%BA%90%E7%A0%81%E5%89%96%E6%9E%90-go%E5%91%BD%E4%BB%A4/#go-build) - [go源码剖析 - go命令/go test](https://www.hitzhangjie.pro/blog/2020-09-28-go%E6%BA%90%E7%A0%81%E5%89%96%E6%9E%90-go%E5%91%BD%E4%BB%A4/#go-test) - [go源码剖析 - go test实现](https://www.hitzhangjie.pro/blog/2020-02-23-go%E6%BA%90%E7%A0%81%E5%89%96%E6%9E%90-gotest%E5%AE%9E%E7%8E%B0/) OK,上面几篇文章详细介绍了下 go tool compile 的工作过程,以及go test生成测试用入口桩代码的过程,但是没有提及 go tool asm、pack、link、buildid 在构建过程中的作用。本文主要是想介绍编译工具链中各个工具的协作,而非单一工具具体是如何做的。所以你也可以不看上面几篇文章,而是将重点放在我们关心的这个协作目标上。 ### 示例准备 go提供了完整的编译工具链,运行 `go tool` 命令可以查看到编译器compile、汇编器asm、链接器link、静态库打包工具pack,以及一些其他的工具。本节我们先关注这些,其他的有需要的时候再介绍。 ```bash $ go tool addr2line asm buildid cgo compile covdata cover doc fix link nm objdump pack pprof test2json trace vet ``` 为了能演示go编译工具链的功能,尽可能让compile、asm、linker、pack这几个工具都能被执行,我们设计如下这个工程实例,详见:[golang-debugger-lessons/30_how_gobuild_works](https://github.com/hitzhangjie/golang-debugger-lessons/tree/master/30_how_gobuild_works) . file1: main.go ```go package main import "fmt" func main() { fmt.Println("vim-go") } ``` file2: main.s ```asm // Copyright 2009 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. #include "textflag.h" // func archSqrt(x float64) float64 TEXT ·archSqrt(SB), NOSPLIT, $0 XORPS X0, X0 // break dependency SQRTSD x+0(FP), X0 MOVSD X0, ret+8(FP) RET ``` file3: go.mod ```go module xx go 1.22.3 ``` ### 执行测试 执行构建命令 `go build -v -x -work`,我们介绍下这里用到的这几个选项: ```bash $ go help build usage: go build [-o output] [build flags] [packages] ... The build flags are shared by the build, clean, get, install, list, run, and test commands: -v print the names of packages as they are compiled. -x print the commands. -work print the name of the temporary work directory and do not delete it when exiting. ... ``` 我们看下go构建过程的输出信息,因为添加了上述几个选项的原因,我们可以看到编译构建过程中执行的各个命令,以及构建临时目录中的产物信息: ```bash $ go build -v -x -work WORK=/tmp/go-build3686919208 xx mkdir -p $WORK/b001/ echo -n > $WORK/b001/go_asm.h # internal cd $HOME/test/xx 🚩/usr/local/go/pkg/tool/linux_amd64/asm -p main -trimpath "$WORK/b001=>" -I $WORK/b001/ -I /usr/local/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -D GOAMD64_v1 -gensymabis -o $WORK/b001/symabis ./main.s cat >/tmp/go-build3686919208/b001/importcfg << 'EOF' # internal # import config packagefile fmt=$HOME/.cache/go-build/1a/1aeb36219a78df45c71149c716fa273649ec980faca58452aaa9184ba8747d05-d packagefile runtime=$HOME/.cache/go-build/ff/ff9a2c1087b07575bc898f6cbded2c2bd65005b7d3ceaec59cd5dc9ef4dd8bcb-d EOF 🚩/usr/local/go/pkg/tool/linux_amd64/compile -o $WORK/b001/_pkg_.a -trimpath "$WORK/b001=>" -p main -lang=go1.22 -buildid -wqdZirDfarB_eqBW8ak/-wqdZirDfarB_eqBW8ak -goversion go1.22.3 -symabis $WORK/b001/symabis -c=4 -nolocalimports -importcfg $WORK/b001/importcfg -pack -asmhdr $WORK/b001/go_asm.h ./main.go 🚩/usr/local/go/pkg/tool/linux_amd64/asm -p main -trimpath "$WORK/b001=>" -I $WORK/b001/ -I /usr/local/go/pkg/include -D GOOS_linux -D GOARCH_amd64 -D GOAMD64_v1 -o $WORK/b001/main.o ./main.s 🚩/usr/local/go/pkg/tool/linux_amd64/pack r $WORK/b001/_pkg_.a $WORK/b001/main.o # internal 🚩/usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/_pkg_.a # internal cp $WORK/b001/_pkg_.a $HOME/.cache/go-build/a8/a8abe4134014b2c51a6c890004545b5381947bf7b46ad92639eef689fda633c3-d # internal 🚩cat >/tmp/go-build3686919208/b001/importcfg.link << 'EOF' # internal packagefile xx=/tmp/go-build3686919208/b001/_pkg_.a packagefile fmt=$HOME/.cache/go-build/1a/1aeb36219a78df45c71149c716fa273649ec980faca58452aaa9184ba8747d05-d packagefile runtime=$HOME/.cache/go-build/ff/ff9a2c1087b07575bc898f6cbded2c2bd65005b7d3ceaec59cd5dc9ef4dd8bcb-d packagefile errors=$HOME/.cache/go-build/89/892ce7f48762195fcd6840c12c5f9ce87785a46c63b0dc07a57865a519122f28-d packagefile internal/fmtsort=$HOME/.cache/go-build/dd/ddfbd9f18abcb9d77cbc7008f82d128c92ff43558ca6b7efc602cda04d7f6442-d packagefile io=$HOME/.cache/go-build/31/313bc3b844204dfa06aa297c9ccdb7c50e8f5a400e6a2d0194022dc91cc2e16f-d packagefile math=$HOME/.cache/go-build/d9/d965e602a715d2aed8249bef0203c0cd6e28e87987bf89a859f6166427adcd30-d packagefile os=$HOME/.cache/go-build/58/5843eabefbd1a16227acf29d96ad1373972d6e6b6db2aabc28c31dc676b5e465-d packagefile reflect=$HOME/.cache/go-build/bf/bfc22ec705a18fff28097e03b3f013e0ae088c1c0c26c9e1ce7cb5f64106a305-d packagefile sort=$HOME/.cache/go-build/5e/5ed02f1d2aa35fd662d38bde42d018a9dc81f1c38efb01f210cba4daeaa54d0f-d packagefile strconv=$HOME/.cache/go-build/da/da217c7dbe580ef4130eed0028da7aa38f8cec1787943e05a24d792dece7f6fa-d packagefile sync=$HOME/.cache/go-build/6e/6e7ba2c9b00da040587f76dcf4ffc872412e07752bca8280065a41d7eb812e07-d packagefile unicode/utf8=$HOME/.cache/go-build/a5/a5a3730633d8e8c948dcd5588bce011bd0bda847ecdc1c8b8db8d802d683bb76-d packagefile internal/abi=$HOME/.cache/go-build/a9/a98408ccf41589aa8b8552dfd9d6ad04a59f9092a73f1d2237a2cca1e9dedfc2-d packagefile internal/bytealg=$HOME/.cache/go-build/0e/0ef7fc32ea503101ae8a71905a3cc725d82f4436e1fb64e23dabc9a559a81717-d packagefile internal/chacha8rand=$HOME/.cache/go-build/74/74c0617b7f700fffb3e2ec0a75511fe4b4442142fd8ea9d28af32c8e87f91a2e-d packagefile internal/coverage/rtcov=$HOME/.cache/go-build/7a/7a8c48e81d34485c0a46d3b762d70b7252ff2a5122d7929976ac1ed316003edf-d packagefile internal/cpu=$HOME/.cache/go-build/fe/fec87c97c3c638490387af5dca95acb3c7ca00cd3d34c4b665dce7ee8143e59a-d packagefile internal/goarch=$HOME/.cache/go-build/0b/0bf1fceb5ecd8badbcb18732b4e517a2f4968c9960af4e0175726a2d0ce8ba31-d packagefile internal/godebugs=$HOME/.cache/go-build/38/387def0b0b5adb9f38a38b5d5301a4816420da0d8d3259354903883ebf3d06ed-d packagefile internal/goexperiment=$HOME/.cache/go-build/75/755756dfc319f00bcffc6745334076209023acfd72ec9f80b665e0e6b8ca7d37-d packagefile internal/goos=$HOME/.cache/go-build/e2/e2b0d1019a4dd99ef01bb1d44e3ce0504234e38fe6dd5bf5e94960dfa0eae968-d packagefile runtime/internal/atomic=$HOME/.cache/go-build/a1/a1ab93c6b342fa82fa28906124bad4a20b5fcb4c23653212bd8973861814fa46-d packagefile runtime/internal/math=$HOME/.cache/go-build/01/01886c1840e6c3e18c9458497803130f0f40342031eda05d66824c0018d028c2-d packagefile runtime/internal/sys=$HOME/.cache/go-build/cc/cc237a5895f1661e82c3a240f72bf165b7c98c49f584233dac2c830d1fd96db9-d packagefile runtime/internal/syscall=$HOME/.cache/go-build/57/57f5686c8b8b90f002882a4d3020168b314b41aff9b7561f3b7fed78985bf682-d packagefile internal/reflectlite=$HOME/.cache/go-build/fc/fc635c76e99ef1256f0df28309730bc72ada766800e7f75f43eacd4a49ac1825-d packagefile math/bits=$HOME/.cache/go-build/b4/b49ee4aa1defd50d4d0dcfa35c74bc03c59487b53ad698f824db7d092fe12c89-d packagefile internal/itoa=$HOME/.cache/go-build/3b/3b4a89fac06e8caef384af48ace1bd2da07824467fe03ad1980ceaeda67983c6-d packagefile internal/poll=$HOME/.cache/go-build/15/1529e1d377fc16952dcba29f52c6a22a942f61a5059c8f9f959095b5089f1ab8-d packagefile internal/safefilepath=$HOME/.cache/go-build/64/641d3e96f0d2f68d3472d7b1e6a695ffd71295a1e4c7028f28f4b2ef031b6914-d packagefile internal/syscall/execenv=$HOME/.cache/go-build/7a/7a6794530a44ee997a0fcbb91f42ac2b1d30a58bf10a82a7ef31b48ee5279ae7-d packagefile internal/syscall/unix=$HOME/.cache/go-build/97/97c10030ba3200bbde9370669d2d453aab43cfb97af080345505cbba2c755a5c-d packagefile internal/testlog=$HOME/.cache/go-build/8b/8b88f2b695d41ad558f1e04ab9c0d0385b0ea6f33d09d1cf5f98f1e6e286cf65-d packagefile io/fs=$HOME/.cache/go-build/53/536225877d64d4db64280b8ceddb0efddf18f3d88f01b0525ed1e1375cdaa4b5-d packagefile sync/atomic=$HOME/.cache/go-build/a8/a8bc9b57a63c717e41c47f1b2561385a3e99ad7e6f1ac998dfa126558fb2a77c-d packagefile syscall=$HOME/.cache/go-build/09/090478bb0bb13e1af21c128b423010e7ce96eb925d5fbe48dc0d9e0003bf90ea-d packagefile time=$HOME/.cache/go-build/c5/c537d62b8dbfa4801ba05947b4cb7ed69b231f00fc275abd287c8d073c846360-d packagefile internal/unsafeheader=$HOME/.cache/go-build/cb/cbfd364d12f2f9873ac2dbe3f709d93e560c6285abbd5800ed08870b0eef13da-d packagefile unicode=$HOME/.cache/go-build/a6/a68c49fe16820f404e05e8b52685c89f9824b3a05241e84176f664b6b26def68-d packagefile slices=$HOME/.cache/go-build/ee/ee5afcbf5fb8afb740704f6aaf3a227ad2304a26abf14792dfe91814e4ecbbe8-d packagefile internal/race=$HOME/.cache/go-build/c5/c5d493a5513e485a53e716d5a2857cfeef7c998bc786b3d7cdba59c6c6b58ec8-d packagefile internal/oserror=$HOME/.cache/go-build/70/70c743407927cf8c172a78fddb04df52b02d264b6e7b25dfbdd6179824a327c3-d packagefile path=$HOME/.cache/go-build/7a/7aac686e9c5205ee6c817e8ed03a971f77c90d90d1fc668cfae54befbcee36e9-d packagefile cmp=$HOME/.cache/go-build/a1/a12133a77c368ad656257d944b4049e56404cc17981f2a0f1f91ae5ab36419f7-d modinfo "0w\xaf\f\x92t\b\x02A\xe1\xc1\a\xe6\xd6\x18\xe6path\txx\nmod\txx\t(devel)\t\nbuild\t-buildmode=exe\nbuild\t-compiler=gc\nbuild\tCGO_ENABLED=1\nbuild\tCGO_CFLAGS=\nbuild\tCGO_CPPFLAGS=\nbuild\tCGO_CXXFLAGS=\nbuild\tCGO_LDFLAGS=\nbuild\tGOARCH=amd64\nbuild\tGOOS=linux\nbuild\tGOAMD64=v1\n\xf92C1\x86\x18 r\x00\x82B\x10A\x16\xd8\xf2" EOF mkdir -p $WORK/b001/exe/ cd . 🚩/usr/local/go/pkg/tool/linux_amd64/link -o $WORK/b001/exe/a.out -importcfg $WORK/b001/importcfg.link -buildmode=exe -buildid=DnmbfNnl2SoT5ZrYeE1X/-wqdZirDfarB_eqBW8ak/b4gs6m2b26a_jZ5hsnkn/DnmbfNnl2SoT5ZrYeE1X -extld=gcc $WORK/b001/_pkg_.a /usr/local/go/pkg/tool/linux_amd64/buildid -w $WORK/b001/exe/a.out # internal mv $WORK/b001/exe/a.out xx ``` ### 构建过程 上述输出中,我们对感兴趣的工具的执行步骤进行了标记(🚩),简单总结如下: 1. 准备构建用的临时目录,后续构建产物都在这个临时目录中,我们可以cd到此目录查看,但是因为涉及到mv操作、rm操作,构建结束后某些中间产物会消失; 2. `go tool asm` 处理汇编源文件main.s,输出汇编文件中定义的函数列表 symabis。如果没有汇编源文件,此步骤会跳过; 3. `go tool compile` 处理go源文件main.go,输出目标文件,注意compile直接将*.o文件加到了静态库_pkg_.a中; 4. `go tool asm` 对汇编源文件执行汇编操作,输出目标文件main.o。注意哦,main.go以及其他go文件对应的目标文件加到了静态库_pkg_.a中; 5. `go tool pack` 将main.o加到静态库文件_pkg_.a中。此时示例module中的源文件都编译、汇编加入_pkg_.a中了; 6. 准备其他需要链接的目标文件列表,已经编译构建好的go运行时、标准库对应的目标文件,全部写入importcfg.link文件; 7. `go tool link` 对_pkg_.a以及importcfg.link中记录的go运行时、标准库进行链接操作,完成符号解析、重定位,生成一个可执行程序a.out,同时在其.note.go.buildid写入buildid信息; 8. 将a.out重命名为module name,这里为xx; 至此这个示例模块的构建过程结束。 ### 本文小节 OK,本文简单介绍了下 `go build` 内部的工作过程,编译器、汇编器、链接器、静态库创建工具、buildid工具,接下来我们还会进一步展开讲下,它们究竟做了什么。但是在我们详细介绍每一个工具的工作之前,我们得把关注点转向它们的最终产物 —— ELF文件。我们得先了解下ELF文件的构成(如节头表、段头表、sections、segments)以及它们的具体作用,了解了这些之后,我们再回头看这些工具是如何协调起来去生成它们的,以及后续其他的工具加载器、调试器又如何利用它们。 ================================================ FILE: book/7-headto-sym-debugger/2-elf.md ================================================ ## 认识ELF文件 ELF ([Executable and Linkable Format](https://en.wikipedia.org/wiki/Executable_and_Linkable_Format)),可执行可链接格式,是Unix、Linux环境下一种十分常见的文件格式,它可用于可执行程序、目标文件、共享库、core文件等。 ### ELF文件结构 ELF文件结构如下图所示,包括ELF文件头 (ELF Header)、段头表 (Program Header Table)、节头表 (Section Header Table)、Sections。Sections位于段头表和节头表之间,并被段头表和节头表引用。 ![img](assets/elf.png) * **文件头**:ELF文件头 (ELF FIle Header),其描述了当前ELF文件的类型(可执行程序、可重定位文件、动态链接文件、core文件等)、32位/64位寻址、ABI、ISA、程序入口地址、Program Header Table起始地址及元素大小、Section Header Table起始地址及元素大小,等等。 * **段头表**:段头表定义了程序的“**执行时视图**”,描述了如何创建程序的进程映像。每个表项定义了一个“段 (segment)” ,每个段引用了0、1或多个sections。段有类型,如PT_LOAD表示该段引用的sections需要在运行时被加载到内存。段头表主要是为了指导加载器进行加载。 举个例子,.text section隶属于一个Type=PT_LOAD的段,意味着会被加载到内存;并且该段的权限为RE(Read+Execute),意味着指令部分加载到内存后,进程对这部分区域的访问权限为“读+可执行”。加载器 (loader /lib64/ld-linux-x86-64.so) 应按照段定义好的虚拟地址范围、权限,将引用的sections加载到进程地址空间中指定位置,并设置好对应的读、写、执行权限(vm_area_struct.vm_flags)。 * **节头表**:节头表定义了程序的“**链接时视图**”,描述了二进制可执行文件中包含的每个section的位置、大小、类型、链接顺序,等等,主要目的是为了指导链接器进行链接。 举个例子,项目包含多个源文件,每个源文件是一个编译单元,每个编译单元最终会生成一个目标文件(*.o),每个目标文件都是一个ELF文件,都包含自己的sections。链接器是将依赖的目标文件和库文件的相同section进行合并(如所有*.o文件的.text合并到一起),然后将符号引用解析成正确的偏移量或者地址。 * **Sections**:ELF文件中的sections数据,夹在段头表、节头表之间,由段头表、节头表引用。不同程序中包含的sections数量是不固定的:有些编程语言会有特殊的sections来支持对应的语言运行时层面的功能,如go .gopclntab, gosymtab;程序采用静态链接、动态链接生成的sections也会不同,如动态链接往往会生成.got, .plt, .rel.text。 下面,我们我们对每个部分进行详细介绍。 ### 文件头(ELF File Header) #### 类型定义 每个解析成功的ELF文件,对应着go标准库类型 debug/elf.File,包含了文件头 FileHeader、Sections、Progs: ```go // A File represents an open ELF file. type File struct { FileHeader Sections []*Section Progs []*Prog ... } // A FileHeader represents an ELF file header. type FileHeader struct { Class Class Data Data Version Version OSABI OSABI ABIVersion uint8 ByteOrder binary.ByteOrder Type Type Machine Machine Entry uint64 } ``` 注意,go标准库FileHeader比man手册中ELF file header少了几个解析期间有用的字段,为了更全面理解文件头各字段的作用,来看下man 手册中的定义: ```c #define EI_NIDENT 16 typedef struct { unsigned char e_ident[EI_NIDENT]; uint16_t e_type; uint16_t e_machine; uint32_t e_version; ElfN_Addr e_entry; ElfN_Off e_phoff; ElfN_Off e_shoff; uint32_t e_flags; uint16_t e_ehsize; uint16_t e_phentsize; uint16_t e_phnum; uint16_t e_shentsize; uint16_t e_shnum; uint16_t e_shstrndx; } ElfN_Ehdr; ``` - e_ident[EI_NIDENT] - EI_MAG0: 0x7f - EI_MAG1: E - EI_MAG2: L - EI_MAG3: F - EI_Class: 寻址类型(32位寻址 or 64位寻址); - EI_Data: 处理器特定的数据在文件中的编码方式(小端还是大端); - EI_VERSION: ELF规范的版本; - EI_OSABI: 该二进制面向的OS以及ABI(sysv,hpux,netbsd,linux,solaris,irix,freebsd,tru64 unix,arm,stand-alone(embeded); - EI_ABIVERSION: 该二进制面向的ABI版本(相同OSABI可能有不兼容的多个ABI版本); - EI_PAD: 这个位置开始到最后EI_NIDENT填充0,读取时要忽略; - EI_NIDENT: e_ident数组长度; - e_type: 文件类型(可重定位文件、可执行程序、动态链接文件、core文件等); - e_machine: 机器类型(386,spark,ppc,etc); - e_version: 文件版本; - e_entry: 程序入口地址(如果当前文件没有入口地址,就填0); - e_phoff: 段头表相对当前文件开头的偏移量; - e_shoff: 节头表相对当前文件开头的偏移量; - e_flags: 处理器特定的flags; - e_ehsize: ELF文件头部结构体大小; - e_phentsize: 段头表中每个条目占用的空间大小; - e_phnum: 段头表中的条目数量; - e_shentsize: 节头表中每个条目占用的空间大小; - e_shnum: 节头表中的条目数量; - e_shstrndx: 存储了节名字的节在节头表中的索引 (可能是.strtab或者.shstrtab); > ps:ELF文件头其他字段都比较容易懂,关于.shstrtab,它的数据存储与.strtab雷同,只是它用来存section名 (man手册显示.strtab除了可以存储符号名,也可以存储Section名)。 > > **String Table (.strtab section)** > > | Index | +0 | +1 | +2 | +3 | +4 | +5 | +6 | +7 | +8 | +9 | > | ------------ | ------ | ------ | ----- | ----- | ------ | ------ | ------ | ----- | ----- | ----- | > | **0** | `\0` | `n` | `a` | `m` | `e` | `.` | `\0` | `V` | `a` | `r` | > | **10** | `i` | `a` | `b` | `l` | `e` | `\0` | `a` | `b` | `l` | `e` | > | **20** | `\0` | `\0` | `x` | `x` | `\0` | ` ` | | | | | > > 假定有上述.strtab,那么idx=0对应的字符串为none,idx=1的对应着字符串为“name.”,idx=7的对应的字符串为“Variable”。对于.shstrtab,它的存储方式与.strtab相同,但是存储的是所有节的名字,而节的名字在.shstrtab中的索引由Elf32/Elf64_Shdr.s_name来指定。 ### 段头表 (Program Header Table) 段头表 (Program Header Table),可以理解为程序的执行时视图(executable point of view),主要用来指导loader如何加载。从可执行程序角度来看,进程运行时需要了解如何将程序中不同部分,加载到进程虚拟内存地址空间中的不同区域。Linux下进程地址空间的内存布局,大家并不陌生,如data段、text段,每个段包含的信息其实是由段头表预先定义好的,包括在虚拟内存空间中的位置,以及段中应该包含哪些sections数据,以及它们的读写执行权限。 #### 类型定义 段头表当然就是一个数组了,我们看看其中每个“段”的定义: ```c typedef struct { uint32_t p_type; Elf32_Off p_offset; Elf32_Addr p_vaddr; Elf32_Addr p_paddr; uint32_t p_filesz; uint32_t p_memsz; uint32_t p_flags; uint32_t p_align; } Elf32_Phdr; typedef struct { uint32_t p_type; uint32_t p_flags; Elf64_Off p_offset; Elf64_Addr p_vaddr; Elf64_Addr p_paddr; uint64_t p_filesz; uint64_t p_memsz; uint64_t p_align; } Elf64_Phdr; ``` 下面详细解释下,上面两个结构分别是面向32位、64位系统下的结构体,其字段含义如下: - p_type: 段类型 - PT_NULL: 该表想描述了一个undefined的段,可以忽略; - PT_LOAD: 该表项描述了一个可加载的段; - PT_DYNAMIC: 该表项描述了一个动态链接信息; - PT_INTERP: 该表项指定了一个interpreter的路径; - PT_NOTE: 该表项指定了notes的位置; - PT_SHLIB: 该类型被保留,但语义未指定。包含这个类型的段表项的程序不符合ABI规范; - PT_PHDR: 该表项指定了段头表本身的位置和size; - PT_LOPROC, PT_HIPROC: 该表项指定了一个范围[PT_LOPROC, PTHIPROC],这个范围内数据用来保存处理特定机制信息; - PT_GNU_STACK: GNU扩展,Linux内核使用该字段来p_flags中设置的Stack的状态;TODO - p_offset: 表示该段相对于文件开头的偏移量; - p_vaddr: 表示该段数据加载到内存后的虚拟地址; - p_paddr: 表示该段在内存中加载的物理地址; - p_filesz: 表示该段在文件中占用的大小; - p_memsz: 表示该段在内存中占用的大小; - p_flags: 表示该段的属性,以位掩码的形式: - PF_X: 可执行; - PF_W: 可写; - PF_R: 可读; - p_align: 表示该段对齐方式; > 注意,又是一些术语使用不够严谨可能导致理解偏差的地方: > > - 内存地址空间中的内存布局,代码所在区域我们常称为代码段(code segment, CS寄存器来寻址)or 文本段(text segment),数据段我们也常称为数据段(data segment,DS寄存器来寻址)。 > - 内存布局中的上述术语text segment、data segment,不是ELF文件中的.text section和.data section,注意区分。 > > 下面的段头表定义给出了一个这样的示例,text segment其实包含了.text section以及其他sections,data segment其实也包含了.data section以外的其他sections。 > > ```bash > // text segment,段索引02,可以看到包含了.text等其他sections > LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 > 0x0000000000000a70 0x0000000000000a70 R E 0x200000 > > // data segment,段索引03,可以看到包含了.data等其他sections > LOAD 0x0000000000000df0 0x0000000000600df0 0x0000000000600df0 > 0x000000000000025c 0x0000000000000260 RW 0x200000 > > 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata > 03 .init_array .fini_array .dynamic .got .got.plt .data .bss > ``` #### 工具演示 下面这个示例,则展示了测试程序 golang-debugger-lessons/testdata/loop2 的完整段头表定义,运行 `readelf -l`查看其段头表,共有7个表项,每个段定义包含了类型、在虚拟内存中的地址、读写执行权限,以及引用的sections。通过 `Section to Segment mapping: Segment Sections...`部分可以看到,最终组织好的: - text segment(编号02的segment其Flags为R+E,表示可读可执行,这就是text segment)包含了如下sections `.text .note.go.buildid`; - rodata segment (编号03的segment其Flags为R,表示只读,就是rodata segment) 包含了 `.rodata .typelink .itablink .gosymtab .gopclntab` 这些go运行时需要的数据; - data segment (编号04的segment其Flags为RW,表示可读可写,就是data segment) 包含了 `.data .bss` 等这些可读写的数据; ```bash $ readelf -l testdata/loop2 Elf file type is EXEC (Executable file) Entry point 0x475a80 There are 6 program headers, starting at offset 64 Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x0000000000000150 0x0000000000000150 R 0x1000 NOTE 0x0000000000000f9c 0x0000000000400f9c 0x0000000000400f9c 0x0000000000000064 0x0000000000000064 R 0x4 LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x00000000000af317 0x00000000000af317 R E 0x1000 LOAD 0x00000000000b0000 0x00000000004b0000 0x00000000004b0000 0x00000000000a6e70 0x00000000000a6e70 R 0x1000 LOAD 0x0000000000157000 0x0000000000557000 0x0000000000557000 0x000000000000a520 0x000000000002e0c0 RW 0x1000 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 0x8 Section to Segment mapping: Segment Sections... 00 01 .note.go.buildid 02 .text .note.go.buildid 03 .rodata .typelink .itablink .gosymtab .gopclntab 04 .go.buildinfo .noptrdata .data .bss .noptrbss 05 06 ``` 一个section中数据最终会不会被加载到内存,也是由引用它的段的类型决定:PT_LOAD类型会被加载到内存,反之不会。 以上面的go程序demo为例: 1).gosymtab、.gopclntab所属的段(段索引值 03)类型是PT_LOAD,表示其数据会被加载到内存,这是因为go runtime依赖这些信息来计算stacktrace,比如 `runtime.Caller(skip)` 或者panic时 `runtime.Stack(buf)`。 2)而.note.go.buildid所属的段(段索引 01)为NOTE类型,只看这个段的话,section .note.go.buildid不会被加载到内存,但是 3)注意到.note.go.buildid还被下面这个段索引为02、PT_TYPE=LOAD的段引用,那这个section最终就会被加载到内存中。 > ps: 一般情况下,.note.* 这种sections就是给一些外部工具读取使用的,一般不会被加载到内存中,除非go设计者希望能从进程内存中直接读取到这部分信息,或者希望core转储时能包含这些信息以供后续提取使用。 本章稍后的章节,会继续介绍ELF段头表信息如何指导loader加载程序数据到内存,以构建进程映像。 ### 节头表 (Section Header Table) 每个编译单元生成的目标文件(ELF格式),将代码和数据划分成不同sections,如指令在.text、只读数据在.rodata、可读写数据在.data、其他vendor自定义sections,等等,实现了对不同数据的合理组织。 在此基础上,节头表 (Section Header Table),定义了程序的链接视图(the linkable point of view),用来指导linker如何对多个编译单元中的sections进行链接(合并相同sections、符号解析、重定位)。 这里就不得不提共享库类型:静态共享库(俗称静态链接库)、动态共享库(俗称动态链接库)。静态共享库,可以理解成包含了多个*.o文件;动态共享库,相当于把相同sections合并,merging not including \*.o 文件。链接生成最终的可执行程序的时候也是要将相同sections进行合并。至于更多的一些细节,此处先不展开。 #### 类型定义 节头表其实就是一系列section表项的数组,我们来看看其中每个描述表项的定义,section数据可根据其中地址、size来读取。 ```c typedef struct { uint32_t sh_name; uint32_t sh_type; uint32_t sh_flags; Elf32_Addr sh_addr; Elf32_Off sh_offset; uint32_t sh_size; uint32_t sh_link; uint32_t sh_info; uint32_t sh_addralign; uint32_t sh_entsize; } Elf32_Shdr; typedef struct { uint32_t sh_name; uint32_t sh_type; uint64_t sh_flags; Elf64_Addr sh_addr; Elf64_Off sh_offset; uint64_t sh_size; uint32_t sh_link; uint32_t sh_info; uint64_t sh_addralign; uint64_t sh_entsize; } Elf64_Shdr; ``` 上面分别是32位、64位的定义,下面详细解释下每个字段的含义: - sh_name: section name的偏移量,即section的名字在.strtab中的偏移量; - sh_type: section类型 - SHT_NULL: 空section,不包含任何数据; - SHT_PROGBITS: 代码段、数据段; - SHT_SYMTAB: 符号表; - SHT_STRTAB: 字符串表; - SHT_RELAG: 重定位表; - SHT_HASH: 符号hash表; - SHT_DYNAMIC: 动态链接表; - SHT_NOTE: 符号注释; - SHT_NOBITS: 空section,不包含任何数据; - SHT_REL: 重定位表; - SHT_SHLIB: 预留但是缺少明确定义; - SHT_DYNSYM: 动态符号表; - SHT_LOPROC, SHT_HIPROC: 定义了一个范围[SHT_LOPROC, SHT_HIPROC]用于处理器特定机制; - SHT_LOUSER, SHT_HIUSER: 定义了一个范围[SHT_LOUSER, SHT_HIPROC]预留给给应用程序; - sh_flags: section标志位 - SHF_WRITE: 进程执行期间可写; - SHF_ALLOC: 进程执行期间需要分配并占据内存; - SHF_EXECINSTR: 包含进程执行期间的指令数据; - SHF_MASKPROC: 预留给处理器相关的机制; - sh_addr: 如果当前section需要被加载到内存中,表示在内存中的虚拟地址; - sh_offset: 表示当前section相对文件开头的偏移量; - sh_size: section大小; - sh_link: 表示要链接的下一个节头表的索引,用于section链接顺序; - sh_info: section额外信息,具体解释依赖于sh_type; - sh_addralign: 对齐方式; - sh_entsize: 表示每个section的大小; #### 工具演示 OK,以测试程序golang-debugger-lessons/testdata/loop2测试程序为例,我们来看下其链接器角度的视图,可以看到其包含了25个sections,每个section都有类型、偏移量、大小、链接顺序、对齐等信息,用以指导链接器完成链接操作。 ```bash $ readelf -S testdata/loop2 There are 25 section headers, starting at offset 0x1c8: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] .text PROGBITS 0000000000401000 00001000 0000000000098294 0000000000000000 AX 0 0 32 [ 2] .rodata PROGBITS 000000000049a000 0009a000 00000000000440c7 0000000000000000 A 0 0 32 ............................................................. [ 4] .typelink PROGBITS 00000000004de2a0 000de2a0 0000000000000734 0000000000000000 A 0 0 32 [ 5] .itablink PROGBITS 00000000004de9d8 000de9d8 0000000000000050 0000000000000000 A 0 0 8 [ 6] .gosymtab PROGBITS 00000000004dea28 000dea28 0000000000000000 0000000000000000 A 0 0 1 [ 7] .gopclntab PROGBITS 00000000004dea40 000dea40 000000000005fe86 0000000000000000 A 0 0 32 ............................................................. [10] .data PROGBITS 000000000054d4e0 0014d4e0 0000000000007410 0000000000000000 WA 0 0 32 ............................................................. [14] .zdebug_line PROGBITS 0000000000588119 00155119 000000000001cc0d 0000000000000000 0 0 1 [15] .zdebug_frame PROGBITS 00000000005a4d26 00171d26 00000000000062e9 0000000000000000 0 0 1 ............................................................. [22] .note.go.buildid NOTE 0000000000400f9c 00000f9c 0000000000000064 0000000000000000 A 0 0 4 [23] .symtab SYMTAB 0000000000000000 001d0000 0000000000011370 0000000000000018 24 422 8 [24] .strtab STRTAB 0000000000000000 001e1370 00000000000109fb 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), I (info), L (link order), O (extra OS processing required), G (group), T (TLS), C (compressed), x (unknown), o (OS specific), E (exclude), l (large), p (processor specific) ``` ### 节 (Sections) #### 类型定义 这里的section指的就是ELF section里面的数据了,就是一堆bytes,它由节头表、段头表来引用。比如节头表表项中有地址、size指向对应的某块section数据。 #### 常见的节 ELF文件会包含很多的sections,前面给出的测试实例中就包含了25个sections。先了解些常见的sections的作用,为后续加深对linker、loader、debgguer工作原理的认识提前做点准备。 - .text: 编译好的程序指令; - .rodata: 只读数据,如程序中的常量字符串; - .data:已经初始化的全局变量; - .bss:未经初始化的全局变量,在ELF文件中只是个占位符,不占用实际空间; - .symtab:符号表,每个可重定位文件都有一个符号表,存放程序中定义的全局函数和全局变量的信息,注意它不包含局部变量信息,局部非静态变量由栈来管理,它们对链接器符号解析、重定位没有帮助。 - .debug_*: 调试信息,调试器读取该信息以支持符号级调试(如gcc -g生成,go build默认生成); - .strtab:字符串表,包括.symtab和.[z]debug_*节引用的字符串值、section名; - .rel.text:一个.text section中引用的位置及符号列表,当链接器尝试把这个目标文件和其他文件链接时,需要对其中符号进行解析、重定位成正确的地址; - .rel.data:引用的一些全局变量的位置及符号列表,和.rel.text有些类似,也需要符号解析、重定位成正确的地址; 如果您想了解更多支持的sections及其作用,可以查看man手册:`man 5 elf`,这里我们就不一一列举了。 #### 自定义节 ELF也支持自定义sections,如go语言添加了.gosymtab、.gopclntab、.note.build.id来支持go运行时、go工具链的一些操作。 #### 工具演示 这里我们来简单介绍下如何查看sections中的内容: - 以字符串形式打印:`readelf --string-dump=
`; - 以十六进制数打印:`readelf --hex-dump=
`; - 打印前先完成重定位,再以十六进制打印:`readelf --relocated-dump=
`; - 打印DWARF调试信息:`readelf --debug-dump=
`; 以go语言为例,首先 `go tool buildid `提取buildid信息,这个其实就是存储在.note.go.buildid section中的。来验证下,首先通过 `go tool buildid`来提取buildid信息: ```bash $ go tool buildid testdata/loop _Iq-Pc8WKArkKz99o-e6/6mQTe-5rece47rT9tQco/8IOigl4fPBb3ZSKYst1T/QZmo-_A8O3Ec6NVYEn_1 ``` 接下来通过 `readelf --string-dump=.note.go.buildid `直接读取ELF文件中的数据: ```bash $ readelf --string-dump=.note.go.buildid testdata/loop String dump of section '.note.go.buildid': [ 4] S [ c] Go [ 10] _Iq-Pc8WKArkKz99o-e6/6mQTe-5rece47rT9tQco/8IOigl4fPBb3ZSKYst1T/QZmo-_A8O3Ec6NVYEn_1 ``` 结果发现buildid数据是一致的,证实了我们上述判断。 本节ELF内容就先介绍到这里,在此基础上,接下来我们将循序渐进地介绍linker、loader、debugger的工作原理。 ### 本节小结 本文较为详细地介绍了ELF文件结构,介绍了ELF文件头、段头表、节头表的定义,以及通过实例演示了段头表、节头表对节的引用,以及如何通过readelf命令进行查看。我们还介绍了一些常见的节的作用,go语言中为了支持高级特性自主扩展的一些节。读完本节内容后相信读者已经对ELF文件结构有了一个初步的认识。 接下来,我们将介绍符号表、符号的内容,这里先简单提一下。说起符号,ELF .symtab、DWARF .debug_* sections都提供了“符号”信息,编译过程中会记录下来有哪些符号,链接器连接过程中会决定将上述哪些符号生成到.symtab,以及哪些调试类型的符号需要生成信息到.debug_* sections。现在来看.debug_* sections是专门为调试准备的,是链接器严格按照DWARF标准、语言设计、和调试器约定来生成的,.symtab则主要包含链接器符号解析、重定位需要用到的符号。.symtab中其实也可以包含用于支持调试的符号信息,主要看链接器是个什么策略。 比如,gdb作为一款诞生年代很久的调试器,就非常依赖.symtab中的符号信息来进行调试。DWARF是后起之秀,尽管gdb现在也逐渐往DWARF上去靠,但是为了兼容性(如支持老的二进制调试、工具链)还是会保留利用符号表调试的实现方式。如果想让gdb也能调试go程序,就得了解gdb的工作机制,在.symtab, .debug_\* sections中生成其需要的信息,see:[GDB为什么同时使用.symtab和DWARF](./92-why-gdb-uses-symtab.md)。 ### 参考文献 1. Executable and Linkable Format, https://en.wikipedia.org/wiki/Executable_and_Linkable_Format 2. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools 3. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub 4. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes 5. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view 6. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk 7. Computer System: A Programmer's Perspective, Randal E.Bryant, David R. O'Hallaron, p450-p479 8. 深入理解计算机系统, 龚奕利 雷迎春 译, p450-p479 9. Learning Linux Binary Analysis, Ryan O'Neill, p14-15, p18-19 10. Linux二进制分析, 棣琦 译, p14-15, p18-19 11. 字符串表示例, https://refspecs.linuxbase.org/elf/gabi4+/ch4.strtab.html 12. Introduction of Shared Libraries, https://medium.com/@hitzhangjie/introduction-of-shared-libraries-df0f2299784f ================================================ FILE: book/7-headto-sym-debugger/3-syms.md ================================================ ## 符号表和符号 在 "认识ELF文件" 一节中,我们有介绍过ELF文件中常见的一些section及其作用,本节我们重点讲述符号表及符号。 ### 生成过程 尽管这部分知识是非常有价值的,但是仍然难免感觉有些枯燥。OK,那我们换个讲解思路,我们先不介绍那些枯燥的符号表格式、符号类型定义,先看看Go编译工具链中符号表是如何生成的吧。 **go编译器的具体工作:** - 会接受go源文件作为输入, - 然后读取源文件进行词法分析得到一系列tokens, - 进而进行语法分析(基于gramma进行规约)得到AST, - 然后基于AST进行类型检查, - 类型检查无误后开始执行函数编译(buildssa、代码优化、生成plist、转换为平台特定机器码), - 最后将结果输出到目标文件中。 这里的*.o文件实际上是一个ar文件(通过 `file \*.o` 可以求证),并不是gcc生成\*.o文件时常用的ELF格式。这种实现思路是go团队借鉴plan9项目的目标文件格式。每个编译单元对应的\*.o文件中包括了两部分,一部分是compiler object file,一部分是linker object file(通过 `ar -t *.o `可以看到内部的 `__.PKGDEF `文件和 `__go__.o `文件)。`-linkobj=???`,分布式构建中为了加速也可以指定只输出compiler object file或者linker object file,比如bazel分布式构建来进行编译加速。 编译器还会在输出的目标文件中记录一些将来由Linker处理的符号信息,这些符号信息实际上就是一个LSym的列表,在前面进行类型检查过程中,编译器会维护这样一个列表,输出到目标文件中。 ```go // An LSym is the sort of symbol that is written to an object file. // It represents Go symbols in a flat pkg+"."+name namespace. type LSym struct { Name string Type objabi.SymKind Attribute Size int64 Gotype *LSym P []byte R []Reloc Extra *interface{} // *FuncInfo, *VarInfo, *FileInfo, or *TypeInfo, if present Pkg string PkgIdx int32 // <<<=== look,这里还需要记录包名信息 SymIdx int32 } ``` 这些LSym符号信息,从源码视角来看,其实就是编译器处理过程中识别到的各类pkg.name(如变量、常量、类型、别名、值、定义位置、指令数据),这里的符号如果类型为 `symbol.Type=SDWARFXXX`,表示这是一个调试符号,linker要识别并处理。 **go链接器的具体工作:** - 除了合并来自多个目标文件中的相同sections以外(输入可能还包含其他共享库,这里暂时不做发散), - linker还需要完成符号解析、重定位, - 逐渐建立起一个全局符号表,最终写入到构建产物中的符号表.symtab中,用于后续的二次链接,比如产物是个共享库, - linker可以做一些全局层面的优化,如deadcode移除, see: [Dead Code Elimination: A Linkers Perspective](https://medium.com/p/d098f4b8c6dc),等等, - 生成DWARF调试信息, - 生成最终的可执行程序或者共享库, OK,这里我们就先不发散太多……前面编译器将LSym列表写入到目标文件之后,Linker就需要读取出来,利用它完成符号解析、重定位相关的工作,一些跨编译单元的导出函数会被最终输出到.symtab符号表中为将来再次链接备用。另外,对那些 `LSym.Type=SDWARFXXX`的符号,linker需要根据DWARF标准与调试器开发者的约定,生成对应的DWARF DIE描述信息写入到.debug_* sections中,方便后续调试器读取。 > go团队设计实现的时候为了更好地进行优化,并没有直接使用ELF格式作为目标文件的格式,而是采用了一种借鉴自plan9目标文件格式的自定义格式。因此go tool compile生成的目标文件,是没法像gcc编译生成的目标文件一样被readelf、nm、objdump等之类的工具直接读取的。尽管go团队并没有公开详细的文档来描述这种目标文件格式,但是go编译工具链提供了go tool nm, go tool objdump等工具来查看这些目标文件中的数据。 为了方便大家理解,我画了下面这个草图,包括了 `go tool compile` 的工作过程,以及与生成符号信息相关的 `go tool link` 的关键步骤。大家如果想查看源码,可以参考此流程来阅读。 how_objfile_created OK,前面介绍了编译器、链接器之间的协作,最终在可执行程序或者共享库中生成符号表、DWARF调试信息的过程。这里以go编译工具链为例,也是为了让大家多认识下go的方方面面。其他语言编译工具链的处理流程、目标文件的格式等等存在不同之处,但是整体来看是相近的,我们就不再继续发散了。感兴趣的读者可以带着这些框架去搜索下对应的资料。OK,接下来我们再来介绍下符号表、符号的相关内容,读者认识就更全面了。 ### 认识符号表 1)符号表.symtab 存储的是一系列符号,每个符号都描述了它的地址、类型、作用域等信息,用于帮助链接器完成符号解析及重定位相关的工作,当然前面也提到它调试器也可以使用它。 关于符号表,每个可重定位模块都有一张自己的符号表: - \*.o文件,包含一个符号表.symtab; - \*.a文件,它是个静态共享库文件,其中可能包含多个\*.o文件,并且每个\*.o文件都独立保留了其自身的符号表(.symtab)。静态链接的时候会拿对应的\*.o文件出来进行链接,链接时符号表会进行合并; - \*.so文件,包含动态符号表.dynsym,所有合并入这个\*.so文件的\*.o文件的符号表信息合并成了这个.dynsym,\*.so文件中不像静态库那样还存在独立的\*.o文件了。链接器将这些\*.o文件合成\*.so文件时,Merging Not Inclusion; - 其他不常见的可重定位文件类型,不继续展开; 2)符号symbol,符号表.symtab中的每一个表项都描述了一个符号,符号的名字最终记录在字符串表.strtab中。符号除了有名字还有一些其他属性,下面继续介绍。 3)字符串表.strtab和.shstrtab 存储的是字符串信息,.shstrtab和.strtab 首尾各有1-byte '\0',其他数据就是 '\0' 结尾的c_string。区别只是,.strtab可以用来存储符号、节的名字,而.shstrtab仅存储节的名字。 如果深究设计实现的话,就是go编译器在编译过程中构建了AST,它知道源码中任意一个符号package.name的相关信息。在此基础上它记录了一个LSym列表,并输出到了目标文件中进一步交给链接器处理。链接器读取并处理后会针对调试类型的LSym生成DWARF调试信息,DWARF调试信息我们将在第八章介绍,其他用于符号解析、重定位后的一些全局符号被记录到最终可执行程序或者共享库的.symtab中,用于后续链接过程。这个.symtab就是一系列 `debug/elf.Sym32 or Sym64`,而 `debug/elf.Symbol`是解析成功之后更容易使用的方式,比如符号名已经从Sym32/64中的字符串索引值转换为了string类型。 ### 认识符号 符号表.symtab包含了一系列符号,描述了程序中全局作用域的函数、变量及链接器所需要的相关信息,如符号的地址和类型。自动变量通常不会被包含在符号表中,因为它们的作用域仅限于定义它的函数或块内,不需要全局可见性。然而,静态局部变量会被包含在符号表中,尽管它们没有全局命名空间的访问权限,但具有文件作用域,特别是在多级嵌套代码中,内部嵌套可能引用了外部块中定义的静态局部变量,链接器进行符号解析时依然依赖符号表中存在相应的描述信息。 ELF 符号表主要记录的是具有外部作用范围的对象,包括: - 全局函数和全局变量 - 静态函数和静态变量(仅对当前源文件或编译单元可见) - 以及其他需要跨文件或模块访问的符号 我们这里所说的符号,是指的.symtab中的表项,并不是DWARF调试信息,它主要是为了方便链接器进行符号解析和重定位而记录的。但是它记录的这些符号信息也确实会被某些调试器使用,尤其是类似DWARF一样的调试信息标准成为业界标准之前。实际上dlv就完全没有使用.symtab,但是gdb有使用,我们在扩展阅读部分也进行了介绍。 还记得我们的初衷吗,“让大家认识到那些高屋建瓴的设计是如何协调compiler、linker、loader、debugger工作的”,读者不妨大胆多问几个为什么?没多少人能不做一番调研就说他精通这些。 - 编译构建过程中.symtab是如何生成的?本文已介绍 - 链接、加载过程中.symtab有什么作用?链接时符号解析、重定位 - 构建产物中的.symtab为什么要保留?链接时符号解析、重定位,调试等 - 删掉它对gdb、dlv之类调试器有没有影响?对gdb有影响,对dlv应该没影响 - 从共享库中删掉它对依赖它的程序的构建有没有影响?链接时链接失败 - 从共享库中删掉它对依赖它的程序的运行有没有影响?加载时动态链接失败 - gdb早期实现可以借助.symtab实现,为什么还需要DWARF?DWARF标准更胜一筹,但是成为业界标准较晚 - gdb现在为什么不弃用.symtab而完全借助DWARF?兼容老的二进制和工具链 我们的学习过程不应该是快餐式的,而应该是脚踏实地的。正视自己内心疑问的每个瞬间,无疑都是一剂帮助我们自我突破、走向更远方的强心剂。 #### 符号定义 下面是 `man 5 elf` 中列出的32位和64位版本符号对应的类型定义,它们成员相同,仅仅是字段列表定义顺序有所不同。 ```c typedef struct { uint32_t st_name; Elf32_Addr st_value; uint32_t st_size; unsigned char st_info; unsigned char st_other; uint16_t st_shndx; } Elf32_Sym; typedef struct { uint32_t st_name; unsigned char st_info; unsigned char st_other; uint16_t st_shndx; Elf64_Addr st_value; uint64_t st_size; } Elf64_Sym; ``` 下面来详细了解下各个字段的作用: - st_name: 符号的名称,是一个字符串表的索引值。非0表示在.strtab中的索引值;为0则表示该符号没有名字(.strtab[0]=='\0') - st_value: 符号的值,对可重定位模块,value是相对定义该符号的位置的偏移量;对于可执行文件来说,该值是一个虚拟内存地址; - st_size: 符号指向的对象大小,如果大小未知或者无需指定大小就为0。如符号对应的int变量的字节数; - st_info: 符号的类型和绑定属性(binding attributes) - STT_NOTYPE: 未指定类型 - STT_OBJECT: 该符号关联的是一个数据对象 - STT_FUNC: 该符号关联的是一个函数 - STT_SECTION: 该符号关联的是一个section - STT_FILE: 该符号关联的是一个目标文件对应的原文件名 - STT_LOPROC, STT_HIPROC: 范围[STT_LOPROC, STT_HIPROC]预留给处理器相关的机制 - STB_LOCAL:符号可见性仅限于当前编译单元(目标文件)内部,多个编译单元中可以存在多个相同的符号名但是为STT_LOCAL类型的符号 - STB_GLOBAL:全局符号对于所有的编译单元(目标文件)可见,一个编译单元中定义的全局符号,可以在另一个编译单元中引用 - STB_WEAK: 弱符号,模拟全局符号,但是它的定义拥有更低的优先级 - STB_LOPROC, STB_HIPROC:范围[STB_LOPROC, STB_HIPROC]预留给处理器相关的机制 - STT_TLS: 该符号关联的是TLS变量 - st_other: 定义了符号的可见性 (visibility) - STV_DEFAULT: 默认可见性规则;全局符号和弱符号对其他模块可见;本地模块中的引用,可以解析为其他模块中的定义; - STV_INTERNAL: 处理器特定的隐藏类型; - STV_HIDDEN: 符号对其他模块不可见;本地模块中的引用,只能解析为当前模块中的符号; - st_shndx: 每个符号都是定义在某个section中的,比如变量名、函数名、常量名等,这里表示其从属的section header在节头表中的索引; ### 读取符号表 go标准库中对ELF32 Symbol的定义 `debug/elf.Sym32/64` 如下,go没有位字段,定义上有些许差别,理解即可: ```go // ELF32 Symbol. type Sym32 struct { Name uint32 Value uint32 Size uint32 Info uint8 // type:4+binding:4 Other uint8 // reserved Shndx uint16 // section } ``` 关于如何读取符号表,可以参考go源码实现:https://sourcegraph.com/github.com/golang/go/-/blob/src/debug/elf/file.go?L489:16。 现在go工具链已经支持读取符号表,推荐大家优先使用go工具链。Linux binutils也提供了一些类似工具,但是对于go程序而言,有点特殊之处: - 如果是编译链接完成的可执行程序,通过readelf -s、nm、objdump都可以; - 但是如果是go目标文件,由于go是自定义的目标文件格式,则只能借助go tool nm、go tool objdump来查看。 可能使用这个类型 `debug/elf.Symbol`会更方便,而且还支持读取动态符号表.dynsym。 ```go // A Symbol represents an entry in an ELF symbol table section. type Symbol struct { Name string Info, Other byte // HasVersion reports whether the symbol has any version information. // This will only be true for the dynamic symbol table. HasVersion bool // VersionIndex is the symbol's version index. // Use the methods of the [VersionIndex] type to access it. // This field is only meaningful if HasVersion is true. VersionIndex VersionIndex Section SectionIndex Value, Size uint64 // These fields are present only for the dynamic symbol table. Version string Library string } ``` 接下来我们来展开了解下如何使用此类工具,以及掌握理解输出的信息。 ### 工具演示 大家看完了符号的类型定义后,肯定产生了很多联想,“变量名对应的symbol应该是什么样”,“函数名对应的symbol应该是什么样”,“常量名呢……”,OK,我们接下来就会结合具体示例,给大家展示下程序中的不同程序构造对应的符号是什么样子的。 代码示例如下,**file: main.go** ```go package main import "fmt" func main() { fmt.Println("vim-go") } ``` #### 列出所有的符号 `readelf -s `,可以显示出程序prog中的所有符号列表,举个例子: ```bash $ readelf -s main Symbol table '.symtab' contains 1991 entries: Num: Value Size Type Bind Vis Ndx Name 0: 0000000000000000 0 NOTYPE LOCAL DEFAULT UND 1: 0000000000000000 0 FILE LOCAL DEFAULT ABS go.go 2: 0000000000401000 0 FUNC LOCAL DEFAULT 1 runtime.text 3: 0000000000402d00 557 FUNC LOCAL DEFAULT 1 cmpbody 4: 0000000000402f50 339 FUNC LOCAL DEFAULT 1 memeqbody 5: 0000000000403100 297 FUNC LOCAL DEFAULT 1 indexbytebody 6: 000000000045f5c0 64 FUNC LOCAL DEFAULT 1 gogo 7: 000000000045f600 43 FUNC LOCAL DEFAULT 1 callRet 8: 000000000045f640 47 FUNC LOCAL DEFAULT 1 gosave_systemsta[...] 9: 000000000045f680 13 FUNC LOCAL DEFAULT 1 setg_gcc 10: 000000000045f690 1380 FUNC LOCAL DEFAULT 1 aeshashbody 11: 000000000045fc00 205 FUNC LOCAL DEFAULT 1 gcWriteBarrier ... ``` #### 查看符号的依赖图 示例中的包名main、函数名main.main、导入的外部包名fmt、引用的外部函数fmt.Println,这些都属于符号的范畴。 “vim-go”算不算符号?其本身是一个只读数据,存储在.rodata section中,其本身算不上符号,但可以被符号引用,比如定义一个全局变量 `var s = "vim-go"` 则变量s有对应的符号,其符号名称为s,变量值引用自.rodata中的vim-go。 我们可以通过 `readelf --hex-dump .rodata | grep vim-go`来验证。上述示例中其实会生成一个临时变量,该临时变量的值为"vim_go",要想查看符号依赖图,可以通过 `go tool link --dumpdep main.o | grep main.main`验证,或者 `go build -ldflags "--dumpdep" main.go | grep main.main` 也可以。 > ```bash > $ go build -ldflags "--dumpdep" main.go 2>&1 | grep main.main > > runtime.main_main·f -> main.main > main.main -> main..stmp_0 > main.main -> go.itab.*os.File,io.Writer > main.main -> fmt.Fprintln > main.main -> gclocals·8658ec02c587fb17d31955e2d572c2ff > main.main -> main.main.stkobj > main..stmp_0 -> go.string."vim-go" > main.main.stkobj -> type.[1]interface {} > ``` 可以看到生成了一个临时变量main..stmp_0,它引用了go.string."vim-go",并作为fmt.Println的参数。 #### 查看符号表&符号 示例代码不变,来介绍下如何快速查看符号&符号表信息: `go build -o main main.go` 编译成完整程序,然后可通过readelf、nm、objdump等分析程序main包含的符号列表,虽然我们的示例代码很简单,但是由于go运行时非常庞大,会引入非常多的符号。 我们可以考虑只编译main.go这一个编译单元,`go tool compile main.go`会输出一个文件main.o,这里的main.o是一个可重定位目标文件,但是其文件格式却不能被readelf、nm分析,因为它是go自己设计的一种对象文件格式,在 [proposal: build a better linker](https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view) 中有提及,要分析main.o只能通过go官方提供的工具。 可以通过 `go tool nm`来查看main.o中定义的符号信息: ```bash $ go tool compile main.go $ go tool nm main.o U U ""..stmp_0 1477 ? %22%22..inittask 1497 R %22%22..stmp_0 13ed T %22%22.main 14a7 R %22%22.main.stkobj U fmt..inittask U fmt.Fprintln 17af R gclocals·33cdeccccebe80329f1fdbee7f5874cb 17a6 R gclocals·d4dc2f11db048877dbc0f60a22b4adb3 17b7 R gclocals·f207267fbf96a0178e8758c6e3e0ce28 1585 ? go.cuinfo.packagename. U go.info.[]interface {} U go.info.error 1589 ? go.info.fmt.Println$abstract U go.info.int 1778 R go.itab.*os.File,io.Writer 1798 R go.itablink.*os.File,io.Writer 15b3 R go.string."vim-go" U os.(*File).Write 156b T os.(*File).close U os.(*file).close U os.Stdout .... ``` `go tool nm`和Linux下binutils提供的nm,虽然支持的对象文件格式不同,但是其输出格式还是相同的,查看man手册,我们了解到: - 第一列,symbol value,表示定义符号处的虚拟地址(如变量名对应的变量地址); - 第二列,symbol type,用小写字母表示局部符号,大写则为全局符号(uvw例外); 运行命令 `man nm`查看nm输出信息: ```bash "A" The symbol's value is absolute, and will not be changed by further linking. "B" "b" The symbol is in the uninitialized data section (known as BSS). "C" The symbol is common. Common symbols are uninitialized data. When linking, multiple common symbols may appear with the same name. If the symbol is defined anywhere, the common symbols are treated as undefined references. "D" "d" The symbol is in the initialized data section. "G" "g" The symbol is in an initialized data section for small objects. Some object file formats permit more efficient access to small data objects, such as a global int variable as opposed to a large global array. "i" For PE format files this indicates that the symbol is in a section specific to the implementation of DLLs. For ELF format files this indicates that the symbol is an indirect function. This is a GNU extension to the standard set of ELF symbol types. It indicates a symbol which if referenced by a relocation does not evaluate to its address, but instead must be invoked at runtime. The runtime execution will then return the value to be used in the relocation. "I" The symbol is an indirect reference to another symbol. "N" The symbol is a debugging symbol. "p" The symbols is in a stack unwind section. "R" "r" The symbol is in a read only data section. "S" "s" The symbol is in an uninitialized data section for small objects. "T" "t" The symbol is in the text (code) section. "U" The symbol is undefined. "u" The symbol is a unique global symbol. This is a GNU extension to the standard set of ELF symbol bindings. For such a symbol the dynamic linker will make sure that in the entire process there is just one symbol with this name and type in use. "V" "v" The symbol is a weak object. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the weak symbol becomes zero with no error. On some systems, uppercase indicates that a default value has been specified. "W" "w" The symbol is a weak symbol that has not been specifically tagged as a weak object symbol. When a weak defined symbol is linked with a normal defined symbol, the normal defined symbol is used with no error. When a weak undefined symbol is linked and the symbol is not defined, the value of the symbol is determined in a system-specific manner without error. On some systems, uppercase indicates that a default value has been specified. "-" The symbol is a stabs symbol in an a.out object file. In this case, the next values printed are the stabs other field, the stabs desc field, and the stab type. Stabs symbols are used to hold debugging information. "?" The symbol type is unknown, or object file format specific. ``` - 第三列,symbol name,符号名在字符串表中索引,对应字符串是存储在字符串表中; 我们回头再看下我们的示例来加深下理解,OK,让我们关注下main函数本身,我们注意到nm输出显示符号 `%22%22.main`是定义在虚地址 `0x13ed`处,并且表示它是一个.text section中定义的符号,那只有一种可能要么是package main,要么是func main.main,其实是main.main。 ```bash $ go tool nm main.o U U ""..stmp_0 1477 ? %22%22..inittask 1497 R %22%22..stmp_0 13ed T %22%22.main 14a7 R %22%22.main.stkobj U fmt..inittask U fmt.Fprintln .... ``` 我们可以通过 `go tool objdump -S main.o`反汇编main.o查看虚地址处对应的信息来求证,我们注意到虚地址 `0x13ed`处恰为func main.main的入口地址。 ```bash $ go tool objdump -S main.o TEXT %22%22.main(SB) gofile../root/debugger101/testdata/xxxx/main.go func main() { 0x13ed 64488b0c2500000000 MOVQ FS:0, CX [5:9]R_TLS_LE 0x13f6 483b6110 CMPQ 0x10(CX), SP 0x13fa 7671 JBE 0x146d 0x13fc 4883ec58 SUBQ $0x58, SP 0x1400 48896c2450 MOVQ BP, 0x50(SP) 0x1405 488d6c2450 LEAQ 0x50(SP), BP fmt.Println("vim-go") 0x140a 0f57c0 XORPS X0, X0 0x140d 0f11442440 MOVUPS X0, 0x40(SP) 0x1412 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:type.string ...... ``` 另外我们也注意到示例中有很多符号类型是 `U`,这些符号都是在当前模块main.o中未定义的符号,这些符号是定义在其他模块中的,将来需要链接器来解析这些符号并完成重定位。 > 在[how-go-build-works](./0-how-go-build-works.md)小节中我们介绍过importcfg.link,还记得吧?go程序构建时依赖了标准库、运行时,需要和这些一起链接才可以。 之前我们提到,可重定位文件中,存在一些.rel.text、.rel.data sections来实现重定位,但我们也提到了,go目标文件是自定义的,它参考了plan9目标文件格式(当然现在又调整了 `go tool link --go115newobj`),Linux binutils提供的readelf工具是无法读取的,go提供了工具objdump来查看。 ```bash $ go tool objdump main.o | grep R_ main.go:5 0x13ed 64488b0c2500000000 MOVQ FS:0, CX [5:9]R_TLS_LE main.go:6 0x1412 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:type.string main.go:6 0x141e 488d0500000000 LEAQ 0(IP), AX [3:7]R_PCREL:""..stmp_0 print.go:274 0x142a 488b0500000000 MOVQ 0(IP), AX [3:7]R_PCREL:os.Stdout print.go:274 0x1431 488d0d00000000 LEAQ 0(IP), CX [3:7]R_PCREL:go.itab.*os.File,io.Writer print.go:274 0x145d e800000000 CALL 0x1462 [1:5]R_CALL:fmt.Fprintln main.go:5 0x146d e800000000 CALL 0x1472 [1:5]R_CALL:runtime.morestack_noctxt gofile..:1 0x1580 e900000000 JMP 0x1585 [1:5]R_CALL:os.(*file).close ``` 我们使用 `grep R_`来过滤objdump的输出,现在我们看到的这些操作指令,其中都涉及了一些需要进行重定位的符号,比如类型定义type.string,比如全局变量os.Stdout,比如全局函数fmt.Fprintln、os.(*file).close。 > ps: plan9中汇编指令R_PCREL, R_CALL,表示这里需要进行重定位,后面会介绍。 这些符号将在后续 `go tool link`时进行解析并完成重定位,最终构建出一个完全链接的可执行程序,可以尝试运行 `go tool link main.o`,会生成一个a.out文件,这就是链接完全的可执行程序了。 ```bash $ go tool link main.o $ ls ./a.out main.o main.go $ ./a.out vim-go ``` 最后需要注意的是,纯go程序是静态链接的,所以最终构建出的可执行程序中是不存在需要动态符号解析的symbol或section的。但如果是cgo编译的,还是会的。 ``` $ ldd -r test1 // 这是一个简单的纯go程序 not a dynamic executable $ ldd -r test2 // 这是一个引用了共享库的cgo构建的go程序 ldd -r seasonsvr linux-vdso.so.1 (0x00007fff35dec000) /$LIB/libonion.so => /lib64/libonion.so (0x00007f7c6f744000) libresolv.so.2 => /lib64/libresolv.so.2 (0x00007f7c6f308000) libpthread.so.0 => /lib64/libpthread.so.0 (0x00007f7c6f0e8000) libc.so.6 => /lib64/libc.so.6 (0x00007f7c6ed12000) libdl.so.2 => /lib64/libdl.so.2 (0x00007f7c6eb0e000) /lib64/ld-linux-x86-64.so.2 (0x00007f7c6f520000) ``` 而对c程序且使用了共享库的,构建出的可执行程序中存在一些这样的符号或section,在后续loader加载程序时会调用动态链接器(如ld-linux)来完成动态符号解析。 ### 本节小结 前面我们结合go测试程序详细介绍了: - 什么是符号&符号表; - 符号表&符号是如何生成的? - 如何读取符号&符号表; - 如何快速查看目标文件中的符号列表&符号依赖关系; - 如何完成链接生成可执行程序; 至此,相信大家已经对符号&符号表有了比较清晰的认识,我们可以继续后续内容了。 ### 参考内容 1. Go: Package objabi, https://golang.org/pkg/cmd/internal/objabi/ 2. Go: Object File & Relocations, Vincent Blanchon, https://medium.com/a-journey-with-go/go-object-file-relocations-804438ec379b 3. Golang Internals, Part 3: The Linker, Object Files, and Relocations, https://www.altoros.com/blog/golang-internals-part-3-the-linker-object-files-and-relocations/ 4. Computer System: A Programmer's Perspective, Randal E.Bryant, David R. O'Hallaron, p450-p479 深入理解计算机系统, 龚奕利 雷迎春 译, p450-p479 5. Linker and Libraries Guide, Object File Format, File Format, Symbol Table, https://docs.oracle.com/cd/E19683-01/816-1386/chapter6-79797/index.html 6. Linking, https://slideplayer.com/slide/9505663/ 7. proposal: build a better linker, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view ================================================ FILE: book/7-headto-sym-debugger/4-syms-resolve-reloc.md ================================================ ## 符号解析 & 重定位 前面详细介绍了符号和符号表的基础知识,这里我们继续介绍下符号解析(symbol resolution)和重定位(relocation)相关内容。 ### 内容概览 每个可重定位目标模块m都有一个符号表,它包含m所定义和引用的符号的信息,在链接器上下文中,有3种不同的符号: - 由m定义并能够被其他模块引用的全局符号; - 由其他模块定义并能被当前模块m引用的全局符号; - 只被模块定义和引用的本地符号(如c语言static修饰的全局函数或全局变量); > 不管是\*.o文件,还是\*.a文件,还是\*.so文件 ……,\*.o文件包含了.symtab,\*.a文件中的每个\*.o文件都包含了.symtab,\*.so文件包含了.dynsym,这些section里面记录了定义了哪些符号。 **符号解析**,指的是对于每个被引用的符号,链接器需要找到这个符号的定义,从哪里找?从所输入的可重定位目标文件列表中,逐一检查各个重定位目标文件的符号表,找到与该引用对应的符号定义。对于仅引用了\*.o和\*.a文件中的符号定义,静态链接器符号解析完立即可以重定位;对于引用了\*.so中的符号定义的情况,静态链接器的处理有所不同,此时静态链接器仅仅是在 `.rel.dyn` section中记录一些重定位条目,用来指导后续的动态链接器完成解析、重定位,也称为lazy binding。 **重定位**,指的是编译器为每个编译单元生成指令、数据sections后,1) 静态链接器需要合并相同sections并为它们安排地址(relocating sections),也为定义的每个符号分配地址 (relocating symbol definitions),2) 当链接器将引用解析到对应的符号定义后,要将引用处替换为符号定义的相对地址或者绝对地址 (relocating symbol references within sections)。对于仅引用\*.o和\*.a中的符号定义的情况,和引用了\*.so中的符号定义的情况,重定位有有所不同。对于前者,静态链接器直接将引用位置替换成相对地址或者绝对地址即可;对于后者,是在loader加载程序时,调用动态链接器来将对应的\*.so文件加载,并在引用定义在\*.so文件中的变量或者函数访问时,才会触发符号解析,此时会在相关的so文件的.dynsym中搜索对应的符号定义,搜索到了后就准备进行重定位,此时要根据.rel.dyn section中的重定位条目的描述(比如重定位类型)完成重定位并更新到.got或者.got.plt对应的地址中去。 符号解析、重定位是一整个工具链协作的过程。通常计算机书籍考虑到篇幅原因,会将其拆开来讲。但是考虑到符号解析和重定位是紧密相关的两个步骤,本来这部分工作原理就没那么直观,不恰当的内容分割(符号解析 与 重定位,静态库 与 动态库的不同处理方式),会让读者理解起来更费劲。所以我反其道而行之,将符号解析和重定位在一篇文章里说透。下面开始详细介绍,如果你感觉上面的总结已经让你“悟"了,您也可以选择性跳过。 ### 解析符号类型 #### 本地符号解析 对那些引用当前模块中定义的本地符号的情况,符号解析是非常简单明了的。编译器只允许每个模块中每个本地符号只有一个定义。编译器还能确保,静态局部变量也会有本地链接符号,且拥有唯一的名字。本地符号解析比较简单。 #### 全局符号解析 但是,对于解析全局符号就棘手多了: 1. 当编译器遇到一个不是在当前模块中定义的符号的时候(可能是变量名或者函数名),它会假设该符号是在其它某个模块中定义的,编译器将为这样的每个符号都生成一个链接器符号表条目,并把它交给链接器进行处理。 2. 链接器连接的时候会读取这个待重定位符号表,然后从所有的输入模块中查找对应的符号定义,如果某个引用符号没有找到其定义,链接器就会输出一个错误。 3. 如果链接器找到了一个引用符号的多次重复定义(多重定义),是直接抛出错误?还是有办法知道该选择哪一个呢?这就涉及到符号的强弱规则问题。 在编译时,编译器向汇编器输出每个全局符号,或者是强(strong)或者是弱(weak),而汇编器把这个信息记录在当前这个可重定位目标文件的符号表里,准确地说是记录在对应符号的字段 `Elf_symbol.binding`字段中。 - 强符号:`(binding & global != 0) && (binding & weak == 0)` - 弱符号:`binding & weak == 1` 根据强弱符号的定义,Unix链接器使用下面的规则来处理多重定义的符号: - 规则1:不允许有多个强符号 - 规则2:如果有一个强符号和多个弱符号,那么选择强符号; - 规则3:如果有多个弱符号,那么从这些弱符号中任意选择一个; 严格遵循这些规则,就可以正确完成全局符号的解析任务。 ### 符号多重定义 在编译和链接过程中,**强符号(Strong Symbol)**和**弱符号(Weak Symbol)**的区分主要由编译器和链接器的规则决定,具体规则如下。 #### 强符号&弱符号 - **强符号**: - 由普通的全局变量或函数定义(未显式标记为弱符号,有初始化或非 `extern`声明)。 ```c int global_var = 42; // 强符号 void func() { ... } // 强符号 ``` - **弱符号**: - 通过编译器选项显式标记为弱(如 GCC 的 `__attribute__((weak))`)。 ```c __attribute__((weak)) int y; // 弱符号 __attribute__((weak)) void bar() {} // 弱符号 ``` - 未初始化的全局变量(在 C 中,未初始化的全局变量默认是强符号,但在某些编译器中可能被视为弱符号,需具体分析)。 - 某些特殊情况下(如 C++ 的模板实例化冲突时可能生成弱符号)。 #### 链接器的行为 - **强符号优先**:若强符号和弱符号同名,链接器选择强符号。 - **多个弱符号**:若只有弱符号同名,链接器可任选一个(通常报错,除非使用 `--allow-shlib-undefined` 等选项)。 下面是一个示例: ```c // 文件1.c __attribute__((weak)) int global = 1; // 弱符号 // 文件2.c int global = 2; // 强符号(覆盖弱符号) ``` 链接后 `global` 的值为 `2`。 #### 应用场景及验证 - **动态库(.so/.dll)**:弱符号允许动态库覆盖主程序的符号(如插件机制)。 - **避免重复定义**:弱符号可用于提供默认实现,允许用户通过强符号覆盖。 - **兼容性处理**:弱符号可用于解决不同库中的符号冲突(如旧版 API 的兼容层)。 使用 `nm` 查看符号类型: ```bash nm your_object_file.o | grep ' T ' # T 表示强符号(函数) nm your_object_file.o | grep ' W ' # W 表示弱符号 ``` 编译器通过语法(是否显式标记 `weak`)和上下文(如初始化状态)决定符号强弱,链接器则依据强弱规则处理符号冲突。弱符号的核心用途是提供灵活的符号覆盖机制。 ### 符号解析&重定位 (静态库) #### 静态库介绍 迄今为止,我们都是假设链接器读取一组可重定位的目标文件,并把它们链接起来,成为一个可执行文件。实际上,所有的编译系统都提供一种机制,允许将所有相关的目标模块打包成为一个单独的文件,称为**静态共享库**(static shared library),简称静态库,它也可以作为链接器的输入。 ![image-20201130032046600](assets/image-20201130032046600.png) 静态库,一种称为存档(archive)的特殊文件格式存储在磁盘中。存档文件是一组连接起来的可重定位目标文件的集合,其中每一个模块文件都有一个头部来描述其大小和位置。存档文件名由后缀.a标识。我们可以通过 `ar`命令来创建静态库。如果您是用go工具对目标模块创建静态库,可通过 `go tool pack`来创建。 当链接器链接输出一个可执行文件时,它只拷贝静态库里被应用程序引用的目标模块。静态库提高了常用代码的复用性,一定程度上节省了每个应用程序因为拷贝待复用模块*.o文件所带来的磁盘存储空间的浪费。 下面是一个静态链接过程的示意图: ![Carnegie Mellon 1 Bryant and O'Hallaron, Computer Systems: A Programmer's Perspective, Third Edition Linking : Introduction to Computer Systems ppt download](assets/slide_29.jpg) main2.c里面调用了vector.h中的函数,这个函数的实现在静态库文件libvector.a中,addvec实现在addvec.o中,multvec实现在multvec.o中,同时main2.c中还使用了libc的io函数,实现包含在libc.a中。现在通过静态链接 `gcc -static -o prog2c main2.o ./libvector.a` 构造一个完整链接的可执行程序,程序加载和运行时无需再执行动态链接。 链接器会检测到该函数调用addvec是在addvec.o中实现的,所以从libvector.a中只提取addvec.o来进行最后的链接,而不是也将multvec.o也链接过来,这种方式也可以节省存储空间占用。 #### 符号解析过程 **链接器如何使用静态库来符号解析呢?**其实这个过程很简单。 在符号解析阶段,链接器从左到右扫描在编译命令上输入的可重定位目标文件和静态库存档文件(命令上列出的.c文件会被转换为对应的.o文件),在这次扫描中,链接器维持一个可重定位目标文件的集合E(这个集合中的文件会被合并起来形成可执行文件),一个未解析的符号(引用了但是尚未定义的符号)集合U,以及一个在前面输入文件中已经定义的符号集合D。初始时,E、U、D都是空集。 - 对于命令上的每个输入文件f,链接器会判断f是一个目标文件,还是一个存档文件,如果f是一个目标文件,那么链接器会把f添加到E,修改U、D来反映f中的符号定义和引用,并继续处理下一个文件; - 如果f是一个静态库存档文件,那么链接器就尝试匹配U中未解析的符号,看看能否在存档文件中找到对应的定义的符号。如果某个存档文件成员m,定义了一个符号来解析U中的一个符号引用,那么就将m加到E中,并且链接器修改U和D来反映m中的符号定义和引用。对存档文件中所有的成员目标文件都反复执行这个过程,直到U和D不再发生变化。在此时,任何不包含在E中的成员目标文件都简单地被丢弃,而链接器将继续处理下一个输入文件; - 如果当链接器完成对命令上输入文件的扫描后,U是非空的,表明存在未解析成功的符号,链接器就会输出一个错误并终止。否则,它会合并并重定位E中的目标文件,从而构出完整的可执行程序文件。 这种处理方式需注意命令行上的库、目标文件的顺序,否则可能会导致符号解析失败。 另外,我们这里提到的是将静态库中的目标文件拿出来进行链接,其实链接器还可以做的更好,如只将引用的函数或者变量来做链接,而将其他未引用的部分移除。这样做的好处是减少目标文件的大小,减少将来加载时对宝贵内存资源的浪费。感兴趣可以阅读我的这篇文章:[dead code elimination: a linker's perspective](https://medium.com/@hitzhangjie/dead-code-elimination-a-linkers-perspective-d098f4b8c6dc)。 #### 重定位过程 一旦链接器完成了符号解析这一步,它就把代码中的每个引用对应的符号定义在哪个输入模块中的哪个位置。此时,链接器需要做两步: - **重定位sections和符号定义**:在这一步中,**链接器将所有相同类型的节(section)合并为同一类型的新的聚合节**。例如,来自输入目标文件的.data节将全部被合并一个节,这个节成为输出的可执行文件的.data节。然后链接器将运行时存储器地址赋值给新的合并后的节,**输入模块定义的的每个节、每个符号定义,也都将得到新分配的运行时地址**。当这一步完成时,程序中的每个指令和全局变量都有唯一的运行时内存地址了。 - **重定位符号引用*:在这一步中,**链接器修改.text section和.data section中每个符号的引用,使得它们指向正确的运行时地址**。为了执行这一步,链接器依赖于一种称为“**重定位条目(relocation entry)**”的可重定位目标模块中的数据结构,我们接下来将会描述这种数据结构。 当汇编器生成一个目标模块时,它并不知道数据和代码最终将存放在内存中中的什么位置。它也不知道这个模块引用的任何外部定义的函数或者全局变量的位置。所以,无论何时汇编器遇到对**最终位置未知的目标引用**,它就会生成一个**重定位条目,告诉链接器在将目标文件合并成可执行文件时应如何修改这个引用**。 **.text的指令相关的重定位条目放在.rel.text中,.data已初始化数据的重定位条目放在.rel.data中。** 下面的类型定义Elf32_Rel是ELF重定位条目的格式: ```c type struct { int offset; // offset of the reference to relocate int symbol:24; // symbol the reference should point to int type:8; // relocation type } Elf32_Rel; ``` - offset表示待重定位的引用的位置(相对于section .text or .data的偏移量) - symbol表示待重定位的引用实际指向的符号 - type表示待重定位的引用应该使用的重定位类型,告知链接器如何修改新的引用 简言之就是链接器此时遇到一个符号引用,但是这个符号不是定义在当前编译单元内的,就会记录一个重定位条目,等后续链接器重定位时再来修正引用的正确地址。 好比再说:嘿linker,等你链接完成之后,请在offset偏移量这个位置处给我填上正确的符号地址偏移量,符号地址的计算规则参考重定位类型。 ELF定义了11种不同类型的重定位类型,有些类型相当神秘,我们只关心其中两种最基本的重定位类型即可: - **R_386_PC32:重定位一个使用32位PC相对地址的引用,一个PC相对地址就是相对当前程序计数器(PC)的偏移量**。当CPU执行一条使用PC进行相对寻址的指令时,执行时就会在当前PC值基础上加上这个偏移量,来得到有效地址(如call指令的目标),PC值存储了下一条待执行指令的地址; - **R_386_32:重定位一个使用32位绝对地址的引用**。通过绝对寻址,CPU直接使用在指令中编码的32位值作为有效地址,不需要进一步修改; OK,讲到这里,如果不涉及到动态链接库,这部分内容对于理解符号解析和重定位已经比较全面了。然而静态共享库的使用尽管有优势,但是也还是有些弊端的,下面我们介绍下动态共享库的优势,以及在使用动态共享库时,符号解析和重定位具体是如何实现的。 ### 符号解析&重定位 (动态库) #### 动态库介绍 前面提了静态库的一些优点,其实它也有明显的缺点,就是各个应用程序复用静态库的时候,会把自己需要的目标文件从静态库中提取出来然后和其他目标文件链接成可执行程序,相当于每个应用程序都或多或少拷贝了一部分代码,代码体积大对磁盘空间、内存空间都会造成浪费。尽管我们曾经提及dead code elimination相关的链接器特性,但是确实还是拷贝了相同的代码。 比如对于系统提供的io相关的库,其实没必要每个应用程序都去拷贝到自身,只要能引用这部分代码的同一份实现即可。动态库就是用来解决静态库的这些不足的。 > ps: 严格来说应该用术语动态共享库(dynamic shared library),在某些系统上也称为动态链接库.dll,在Linux下是以*.so为扩展名,经常称为共享库或者动态库。 共享库通过两种方式达成共享的目标: - 首先,在文件系统中,对于一个库,只有一个.so文件,所有引用该库的可执行程序都共享这个.so文件中的代码和数据,而不是像静态库的内容那样还要被拷贝和嵌入到引用它们的可执行程序文件中; - 其次,在内存中,一个共享库的.text section的同一个副本可以被不同的正在运行的进程共享,联想下通过mmap时可以将指定文件映射到指定内存区,同时还可以限制该内存区的访问权限为“共享访问”还是“排他性访问”; 创建静态库使用命令 `ar`,创建动态共享库可使用命令 `gcc -shared -fPIC`来完成。 #### lazy binding 下面是一个动态链接过程的示意图: ![image-20201130032258561](assets/image-20201130032258561.png) main2.c中使用了vector.h中的函数,对应实现在libvector.so这个共享库中,现在是通过动态链接技术进行链接的,然后生成一个可执行程序。 这里的思路是,当创建可执行程序时,静态链接器linker执行部分处理,然后在程序加载时再由加载器loader去调用动态链接器完成最终链接,成为一个完整的可运行程序。 - 静态链接器处理逻辑,指的是这个阶段如果有需要多个目标文件可以执行静态链接的,则执行静态链接。这个时候并没有拷贝任何共享库的代码或数据到可执行文件中,而只是拷贝了一些重定位和符号表信息,用来指导后续动态链接器继续完成对libvector.so中定义的符号的引用和重定位操作。 - 当加载器(kernel的一部分)加载和运行可执行文件时,加载部分链接的可执行文件之后,接着注意到它包含一个.interp section,这个section包含了动态链接器的路径名,动态链接器本身就是一个共享库(如在Linux上为ld-linux.so)。**和加载静态链接的程序所不同的是,加载器此时不再将控制权直接传递给应用程序了,而是先加载并运行这个动态链接器ld-linux.so,先来完成动态链接 **。 **动态链接器会执行下面的重定位操作来完成链接任务:** - 加载并重定位libc.so的文本和数据到某个内存段; - 加载并重定位libvector.so的文本和数据到另一个内存段; - 重定位可执行程序中的引用,将其替换为libc.so和libvector.so定义的符号的地址; 这步操作并不是一次性全部执行完成的,而是随着程序执行,访问到了定义在动态库中的引用(数据或者函数)时,实际上对这些内容的访问指令被链接器改写成了对 `.got .got.plt`的访问,首次访问时对应的表项中的地址都不是有效地址,而是会触发动态链接器的介入来完成符号解析(.dynsym),然后再参考.rel.dyn section中的重定位条目进行重定位,将正确地址写入 `.got .got.plt`,下次访问时就不用重复解析了。 完成上述操作后,动态链接器将控制传递给应用程序,从这个时候开始,共享库的位置就固定了,并且在进程执行过程中都不会改变。 #### 位置无关代码 动态链接中,与位置无关的代码(PIC,Position Independent Code)就显得非常重要。 **那为什么位置无关代码这么重要呢?** 共享库的一个主要目的就是允许多个正在运行的进程共享内存中相同的库代码,因而节约宝贵的内存资源。那么多个进程是如何共享程序的一个拷贝的呢? **一种方法是给每个共享库分配一个事先准备好的专用的地址空间片(chunk),然后要求加载器总是在这个地址处加载共享库。**虽然这种方法很简单,但是它也造成了一些严重的问题。 - 首先,它对地址空间的使用效率不高,因为即使一个进程不使用这个库,那部分空间还是会被分配出来; - 其次,它也难以管理,我们将不得不保证没有chunk重叠,每当一个库修改了之后,我们必须确认它的已分配的chunk还适合它的大小,如果不适合了就要重新分配一个新的chunk。并且如果我们创建了一个新的库,还需要为他分配一个新的chunk。 随着时间发展,假设一个系统中有了成百个库、各种库版本,就很难避免地址空间分列成大量小的、未使用而又不能再使用的空洞。甚至更糟糕的是,对每个系统而言,库在内存中的分配都是不同的,这就引起了更令人头痛的管理问题。 **一种更好的方法是编译库代码,使得不需要链接器修改库代码就可以在任何地址加载和执行这些代码。这样的代码就叫做与位置无关的代码(PIC,Position Independent Code)。**用户可以使用 `gcc -fPIC`生成位置无关代码。 在一个IA32系统中,对**同一个目标模块中过程的调用是不需要特殊处理的,因为引用地址是相对PC值的偏移量,已经是PIC了**。然而**对外部定义的过程调用和对全局变量的引用通常不是PIC,它们都要求在链接时符号解析并重定位**。 #### PIC数据引用 编译器通过运用以下事实来生成对全局变量的PIC引用:无论我们在内存中的何处加载一个目标模块(包括共享目标模块),**数据段总是分配成紧随在代码段后面。因此代码段中任何指令和数据段中任何变量之间的距离都是一个运行时常量,与代码段和数据段的物理地址是无关的**。 为了运用这个事实,编译器在数据段开始的地方的创建了一个表,叫做**全局偏移表(GOT,Global Offset Table)**。在GOT中,每个被这个目标文件(模块)引用的全局数据对象都有一个条目。编译器还为GOT中每个条目生成一个重定位记录。在加载时,动态链接器会重定位GOT中的每个条目,使得它包含正确的绝对地址。每个引用全局数据的目标文件(模块)都有自己的GOT。 > 下一小节会介绍,链接器如何基于重定位记录完成重定位操作。 在运行时,使用下面形式的代码,通过GOT间接地引用每个全局变量: ```asm call L1 L1: popl %ebx ; ebx contains the current PC addl $VAROFF, %ebx ; ebx points to the GOT entry for var movl (%ebx), %eax ; reference indirect through the GOT movl (%eax), %eax ``` 这里的代码比较有趣,首先call L1将把返回地址(L1地址)入栈,接下来popl %ebx刚好把入栈的返回地址给pop出来到%ebx中,其中$VAROFF是一个常数偏移量,给%ebx增加这个常数偏移量使其指向GOT表中适当的条目,该条目包含数据项的绝对地址。然后通过两条movl指令(间接地通过GOT)加载全局变量的内容到寄存器%eax中。 - GOT表项中怎么会包含数据项的绝对地址的呢?动态链接器对GOT表项逐个重定位的时候,会根据指令与数据之前的固定偏移量关系,加上代码段起始物理地址,来算出每个GOT表项的绝对地址; - $VAROFF是怎么得到的呢?前面提过了,指令、数据之间的距离是个固定偏移量,这个在静态链接重定位时就已经算出来了,现在只是在指令物理地址上加这个固定偏移量来得到数据的绝对地址而已; 可以很明显地发现,**访问一个全局变量现在是用了5条指令,而非一条指令,PIC代码有性能缺陷**。此外,还需要一个额外的对GOT的内存引用,而且PIC还需要用一个额外的寄存器来保持GOT条目的地址,在具有大寄存器文件的机器上,这不是一个大问题,然而在寄存器供应不足的IA32系统中,就可能有问题。 #### PIC函数调用 PIC代码当然也可以用相同的方法来解析外部过程调用: ```asm call L1 L1: popl %ebx ; ebx contains the current pc addl $PROCOFF, %ebx ; ebx points to GOT entry for proc call *(%ebx) ; call indirect through the GOT ``` 不过,**这种方法对每一个运行时过程调用都要求用3条额外指令来完成,性能肯定好不了**。 > ps:为什么go程序采用静态链接而非动态链接? > > - 静态链接不存在动态链接库被篡改的问题,相对来说更健壮点; > - 也不存在程序启动时还需要动态链接的问题,相对来说启动速度可能也要快点; > - 函数调用不存在这里引入的额外多条指令问题,函数调用开销小一点; > > 针对第3点,其实动动脑子也可以优化一下,比如只在第一次借助GOT的时候是多条指令,此时算出来的地址就可以为后续调用复用了。 > > 这就是接下来即将提到的“延迟绑定”技术。 与前面方法相比,**延迟绑定(lazy binding)** 更聪明地解决了这里的调用开销问题。**将过程地址的绑定推迟到第一次调用该过程时,第一次调用过程的运行时开销较大,但是其后的每次调用都只会花费一条指令和一个间接的内存引用的开销。** 延迟绑定是通过两个数据结构之间简洁但又有些复杂的交互来实现的,这两个数据结构是**GOT**和**过程链接表(PLT,Procedure Linkage Table)**。如果一个目标文件中有调用定义在共享库中的函数,那么它就会有自己的GOT和PLT,GOT是.data section的一部分,PLT是.text section中的一部分。 > 注意:也有的编译工具链在组织的时候,会将其组织在独立的sections中,如ELF section **.got .got.plt**。 下图是示例程序main2.o的GOT的格式,头三条GOT条目比较特殊:GOT[0]包含.dynamic段的地址,这个段包含了动态链接器用来绑定过程地址的信息,比如符号表的位置和重定位信息。GOT[1]包含定义这个模块的一些信息。GOT[2]包含动态链接器的延迟绑定代码的入口点。 ![image-20201130061110246](assets/image-20201130061110246.png) 定义在共享库中并被main2.o调用的每个过程在GOT中都会有一个GOT条目,从GOT[3]开始的都是。对于示例程序,我们给出了printf和addvec的GOT条目,printf定义在libc.so中,而addvec定义在libvector.so中。 下图展示了实例程序的PLT。PLT是一个数组,其中每个PLT条目是16字节,第一个条目PLT[0]是一个特殊条目,它跳转到动态链接器中。每个被调用的过程在PLT中都有一个PLT条目,从PLT[1]开始都是。在图中,PLT[1]对应于printf,PLT[2]对应于addvec。 开始时,在程序被动态链接并开始执行后,过程printf和addvec被分别绑定到它们对应的PLT条目的第一条指令上。现在指令中假如存在addvec的调用,有如下形式: ```asm 80485bb: e8 a4 fe ff ff call 8048464 ``` 当addvec第一次调用时,控制传递到PLT[2]的第1条指令处(地址为8048464),该指令通过GOT[4]执行一个间接跳转。开始时,每个GOT条目包含相应的PLT条目中的pushl这条指令的地址,所以开始时GOT[4]条目中的内容为0x804846a,现在执行 `jmp *GOT[4]`之后,相当于饶了一圈回到了PLT[2]的第2条指令处,这条指令将addvec符号的ID入栈,第3条指令则跳转到PLT[0]。 好戏开始了,PLT[0]中的代码将GOT[1]中的标识信息的值入栈,然后通过GOT[2]间接跳转到动态链接器(ld-linux.so)中,动态链接器用两个栈顶参数来确定addvec的位置,然后用这个算出的新地址覆盖掉GOT[4],并跳过去执行把控制传递给了addvec。 ![image-20201130061125017](assets/image-20201130061125017.png) 下一次在程序中调用addvec时,控制像前面一样传递给PLT[2],不过这次通过GOT[4]的间接跳转可以直接将控制传递给addvec了,从此刻起,唯一额外的开销就算是对间接跳转存储器的引用了,再也不会兜一大圈,函数调用效率上有了明显提升。 简单总结,就是说对于动态链接的程序,程序中如果涉及到调用的函数是在共享库中定义的,第一次执行该函数时会通过PLT、GOT的交互来触发ld-linux动态链接操作,完成后会把正确的地址覆盖到GOT[idx]中去,后续通过PLT去 `jmp *GOT[idx]`再次调用该函数时就会直接跳转到函数地址,不用再兜圈子计算了,效率当然会比每次都重定位有质的提升! ### 符号解析&重定位 (运行时) 前面介绍了静态链接时的符号解析、重定位处理过程,也介绍了程序加载时的动态链接的符号解析、重定位处理过程。 **动态链接除了链接时、加载时的链接,还有运行时链接的情况:** - 通过 `dlopen`来加载一个共享库; - 通过 `dlsym`来解析一个符号; - 通过 `dlclose`卸载一个共享库; 这部分简单了解下即可,相信大家即使不深入了解,也能猜到大致的实现方式是怎样的,我们就不过多展开了。 ### 本节小结 本文开头首先总结了下符号解析&重定位的大致任务,然后我们展开进行了全面系统性的介绍。从符号解析的类型本地符号解析、全局符号解析,到引出如何解决符号多重定义问题、应用场景,我们介绍了符号的强弱规则以及编译器、链接器的处置办法。在有了这些基础之后,我们对静态链接、动态链接过程中的符号解析和重定位进行了非常细致、深入又恰到好处的讲解。我相信读者朋友们看完这篇文章,应该对链接器(静态链接器+动态链接器)的工作原理有了更深入的认识。 而我们的这种看似漫无目的的学习,尽管看上去这部分内容与调试器无关,但是在编译工具链的理解上又加深了一笔,而且我们排除了各种各样的”符号"的理解不到位的地方,这就是它的价值,我们在后续学习调试器相关的内容时,我们再提到调试符号时,我们也不会再陷入"你说的符号是个什么东西"这样的基础问题中去。 ### 参考内容 1. Go: Package objabi, https://golang.org/pkg/cmd/internal/objabi/ 2. Go: Object File & Relocations, Vincent Blanchon, https://medium.com/a-journey-with-go/go-object-file-relocations-804438ec379b 3. Golang Internals, Part 3: The Linker, Object Files, and Relocations, https://www.altoros.com/blog/golang-internals-part-3-the-linker-object-files-and-relocations/ 4. Computer System: A Programmer's Perspective, Randal E.Bryant, David R. O'Hallaron, p450-p479 5. 深入理解计算机系统, 龚奕利 雷迎春 译, p450-p479 6. Linker and Libraries Guide, Object File Format, File Format, Symbol Table, https://docs.oracle.com/cd/E19683-01/816-1386/chapter6-79797/index.html 7. Linking, https://slideplayer.com/slide/9505663/ 8. Dead Code Elimination: A Linker's Perspective, https://medium.com/@hitzhangjie/dead-code-elimination-a-linkers-perspective-d098f4b8c6dc 9. Learning Linux Binary Analysis, Ryan O'Neill, p14-15, p18-19 10. Linux二进制分析, 棣琦 译, p14-15, p18-19 ================================================ FILE: book/7-headto-sym-debugger/5-loading.md ================================================ ## 程序加载 从我们最初编写的源程序,经过编译工具链处理后,源文件(ASCII or UTF-8编码文件)现在已经被转换为一个可执行的二进制文件,这个二进制文件包含了加载程序到内存运行所需的必要信息。前面我们提及过有些引用了动态共享库的情景,需要在程序加载器加载程序时,调用动态链接器继续完成符号解析和重定位的处理逻辑。然后,加载器才会将控制权限转入我们的二进制程序入口执行。 下面我们来展开详细看看。 ### 可执行程序 我们已经了解了,静态链接阶段是如何将多个目标模块合并成一个可执行目标文件的,下图是个典型的ELF可执行文件结构: image-20201201091733025 ELF Header中还包括了可执行程序的入口点(entry point),也就是当程序运行时要执行的第一条指令的地址。.text、.rodata、.data section和可重定位目标文件中的对应section是相似的,不过是,这些section已经被重定位到它们最终的运行时内存虚拟地址了。.init section定义了一个小函数,叫做_init,程序的初始化代码会调用它。如果可执行程序是完全链接的,这种情况下就不再需要.rel.data、.rel.text section,但如果涉及到动态共享库符号引用,这里面应该还会包含.rel.dyn section来用于完成后续的动态链接器的符号解析和重定位。 ELF可执行文件被设计得很容易加载到内存中,可执行文件的连续的片(chunk)被映射到连续的内存段。段头表(program header table)描述了这种映射关系。下图展示了某个可执行文件的段头表,是由objdump输出的。 image-20201201092246402 > 解释下上述输出的部分字段: > > - offset:文件偏移量; > - vaddr/paddr:虚拟/物理地址; > - align:段对齐; > - filesz:目标文件中的段大小; > - memsz:内存中的段大小; > - flags:权限; 从段头表中,我们看到可执行文件段头表初始化了两个内存段。 **第1行和第2行告诉我们第1个段(代码段):** - align,对齐到一个4KB的边界; - flags,有读权限和执行权限; - vaddr+memsz,起始地址位于内存0x08048000处,共占用内存0x448字节; - off+filesz,并且被初始化为可执行程序的前0x448字节; 这个segment很明显是存了代码,通常会包含.text section中的数据。 **第3行和第4行告诉我们第2个段(数据段):** - align,对齐到一个4KB的边界; - flags,有读权限和写权限,没有执行权限; - vaddr+memsz,开始于内存地址0x08049448处,总的内存大小为0x104字节; - off+filesz,并用从文件偏移0x448处开始的0xe8个字节对内存段进行初始化; 这个segment在这个示例中,偏移0x448处正是.data开始的位置。另外,该段中剩下的字节对应于运行时将被初始化为零的.bss数据。 ### 加载器原理 要运行一个可执行目标文件p,可以在Linux shell里面键入它的名字,如: ```bash $ /path-to/p ``` 因为p不是一个内置的shell命令,所以shell会认为p是一个可执行目标文件,通过调用某个驻留在内存中的称为**加载器(loader)**的操作系统代码来运行它。任何Linux程序都可以通过调用**execve族函数**来调用加载器,加载器会将可执行目标文件中的代码和数据从磁盘拷贝到内存中,然后通过跳转到程序的第一条指令或者入口点(entry point)位置来执行该程序。这个将程序拷贝到内存并运行的过程叫做“**加载(loading)**”。 每个Linux程序(进程)都有一个运行时存储器映像,类似于下图所示的那样: - 在32位Linux系统中,代码段总是从0x08048000处开始; - 数据段是在接下来的下一个4KB对齐的地址处; - 运行时堆在读写段(.data、.bss)之后接下来的第一个4KB对齐的地址处,并通过malloc库(实际上是brk系统调用)从低地址向高地址增长; - 用户栈总是从最大的合法用户地址开始,从高地址向低地址方向增长; - 在用户栈更上部的段是为操作系统内核代码和数据准备的,用户程序无权访问; - 介于用户栈、运行时堆之间,是为共享库保留的; image-20201201094146995 当加载器运行时,它将创建如上图所示的存储器映像。在可执行文件ELF文件头的指导下,加载器将可执行文件的相关内容拷贝到代码段和数据段。接下来,加载器跳转到程序的入口点处。 以C程序为例,这里的启动代码通常就是符号_start的地址。在\_start地址处的启动代码(startup code)是在目标文件ctrl.o中定义的,对所有的C程序都一样。下图展示了启动代码中的关键函数调用序列。 在从.text和.init section中调用了初始化例程之后,启动代码调用atexit例程,这个程序附加了一系列在应用程序正常终止时应该调用的程序(exit函数在运行atexit注册的函数,然后通过系统调用\_exit将控制返回给操作系统)。接着,启动代码将调用应用程序的main函数,它会开始执行我们的C代码。在应用程序返回之后,启动代码调用\_exit程序,它将控制权返回给操作系统。 image-20201201095355536 上述过程可以用下图简单概括: execve **这个过程中加载器是如何工作的呢?** 首先,考虑到加载时是否需要动态链接,加载器的工作方式是不同的,我们先笼统的介绍一下。 Unix系统中的每个程序都运行在一个进程上下文中,都有自己的进程地址空间。当shell运行一个程序时,父shell进程会生成一个子shell进程,它是父进程的一个复制品。子进程通过调用**execve系统调用来启动加载器**。 加载器会剔除子进程中现有的存储器段,并创建一组新的代码段、数据段、堆和栈段。新的堆段和栈段被初始化为零。通过将虚拟地址空间中的页映射到新的可执行文件的页,新的代码和数据段被初始化为了新的可执行文件的内容。最后,加载器跳转到_start地址,它最终会调用应用程序的main函数。除了一些头部信息,在加载过程中没有任何从磁盘到存储器的数据拷贝动作,直到CPU引用一个被映射的虚拟内存页面时会引起缺页异常(page fault)才会触发实际的拷贝动作,此时操作系统利用页面调度机制自动将页面从磁盘传送到存储器,OK之后,进程继续执行。 上述过程只是笼统的介绍,实际的加载过程要考虑的问题更多。 ### 链接与加载 链接,指的是,链接器将多个可重定位文件(目标文件、库文件)进行符号解析、重定位、将多个文件结合成一个可执行程序的过程。 加载,指的是,加载器将可执行程序、共享库等从磁盘加载到内存,组织好内存布局为程序执行做好准备的过程。 可以肯定的是,**链接器(尤其是动态链接器)和加载器之间不是完全孤立的,它们之间也是一种协作关系。** 在Linux系统中,链接的方式**静态链接**和**动态链接**,加载的方式也分为**静态加载和动态加载**。而且它们可以有2x2=4种不同组合,各有特点和应用场景。 1. **静态链接与静态加载**: **静态链接**是指在编译时将所有需要的库文件(通常是静态库,后缀为 `.a`)直接嵌入到可执行文件中。链接器 `/usr/bin/ld` 负责将这些库文件链接到最终的可执行文件中。 **静态加载**则是指在程序运行时,所有的代码都已经包含在可执行文件中,因此不需要额外的库文件,加载器是内核中的ld-linux.so。 2. ~~**静态链接与动态加载**: ~~在Linux上,可以使用 `dlopen` 等函数动态加载共享库(`.so`),但静态库并不支持这种方式,因此在实际应用中未见到静态库的动态加载。 3. **动态链接与静态加载**: **动态链接**是指在加载时将对动态库(后缀为 `.so`)的引用进行符号解析、重定位,而不是将库的代码直接包含在可执行文件中。动态链接器是有加载器ld-linux.so来完成的。 **静态加载**前面已经提过,程序是完整的可执行程序,不再需要额外的库文件,加载器就是内核中的ld-linux.so,而且它也是实际上的动态链接器。 这里的加载过程、动态链接过程,实际上可以细分为初始loading阶段、动态链接阶段、后loading阶段, - 初始loading阶段就是将对应的segments mmap映射到进程虚地址空间, - 然后动态链接期间会将符号解析&重定位确定下来的地址更新.got地址处对应的数据, - 后加载阶段可以通过dlopen等函数进行运行时加载、解析、重定位,这部分就是后面的动态链接与动态加载的方式。 4. **动态链接与动态加载**: 在**动态链接**和**动态加载**的情况下,程序在运行时可以通过 `libdl` 库使用 `dlopen` 函数动态加载库。 链接、加载的工作分散在 `libdl` 和用户程序中,例如,使用 `dlopen` 动态加载库,使用 `dlsym` 解析动态链接的符号。 >ps: 动态链接与动态加载的这种方式提供了更大的灵活性,使得程序可以根据需要加载和使用库。 ### 总结 本文介绍了程序被加载运行时的执行细节,考虑到链接(静态链接、动态链接)和加载(静态加载、动态加载)的多种方式,本文分别介绍了这几种链接、加载组合方式及适用场景。我们也特别描述了下,使用了动态共享库的情景下,loader加载程序、完成动态链接,以及可能后续的动态加载库、动态解析符号的这几个不同的阶段。 现在我们从源代码到编译、到链接、到加载执行的过程细节都介绍到了。接下来我们会在第6小节快速浏览下相关的go标准库go/src/debug的使用,这其中包含了ELF相关、符号表相关、DWARF相关的支持代码。然后第7小节我们会简单总结下go标准库go/src/debug/dwarf相关的实现,了解这些还是不够的,第8章我们会深入介绍下DWARF是如何对高级语言源程序构造进行描述的,比如如何描述一个函数、一个类型等。 OK,我们离目标又进了一步,让我们继续吧。 ### 参考内容 1. Computer System: A Programmer's Perspective, Randal E.Bryant, David R. O'Hallaron, p450-p479 2. 深入理解计算机系统, 龚奕利 雷迎春 译, p450-p479 3. What are the executable ELF files respectively for static linker, dynamic linker, loader and dynamic loader, Roel Van de Paar, https://www.youtube.com/watch?v=yzI-78zy4HQ 4. Advanced Programming in Unix Environment ================================================ FILE: book/7-headto-sym-debugger/6-gopkg-debug/1-elf.md ================================================ ## pkg debug/elf 应用 ### 数据类型及关系 标准库提供了package`debug/elf`来读取、解析elf文件数据,相关的数据类型及其之间的依赖关系,如下图所示: ![debug/elf](assets/gopkg-debug-elf.png) 简单讲,elf.File中包含了我们可以从elf文件中获取的所有信息,为了方便使用,标准库又提供了其他package `debug/gosym`来解析.gosymtab符号信息、.gopclntab行号表信息,还提供了`debug/dwarf`来解析.[z]debug_\*调试信息。 ### 常用操作及示例 #### 打开一个ELF文件 通过命令选项传递一个待打开的elf文件名,然后打开该elf文件,并打印elf.File的结构信息。这里我们使用了一个三方库go-spew/spew,它基于反射实现能够打印出elf.File结构中各个字段的信息,如果字段也是组合类型也会对齐进行递归地展开。 ```go package main import ( "debug/elf" "fmt" "os" "github.com/davecgh/go-spew/spew" ) func main() { if len(os.Args) != 2 { fmt.Fprintln(os.Stderr, "usage: go run main.go ") os.Exit(1) } prog := os.Args[1] file, err := elf.Open(prog) if err != nil { panic(err) } spew.Dump(file) } ``` 运行测试`go run main.go ../testdata/loop`,这个结构非常复杂,为了方便读者查看,我删减了部分内容。 不难看出,ELF文件中包含了如下关键信息: - FileHeader,即ELF Header; - Sections,Sections中每个Section都包含了一个elf.SectionHeader定义,它取自ELF文件中的节头表; - Progs,Progs中每个Prog都包含了一个elf.ProgHeader定义,它取自ELF文件中的段头表; elf.NewFile()读取ELF文件内容时根据ELF文件头中的Class类型(未知/32bit/64bit),在后续读取ELF文件内容时会有选择地使用Header32/64、Prog32/64、Section32/64中的类型,不管是32bit还是64bit,最终都赋值到了elf.File中的各个字段中并返回elf.File。 通过打印信息,细心的读者会发现: - 对于sections,我们可以看到section具体的名称,如.text、.rodata、.data; - 对于segments,也可以看到segment具体的类型,如note、load,还有其虚拟地址; ```bash (*elf.File)(0xc0000ec3c0)({ FileHeader: (elf.FileHeader) { Class: (elf.Class) ELFCLASS64, Data: (elf.Data) ELFDATA2LSB, Version: (elf.Version) EV_CURRENT, OSABI: (elf.OSABI) ELFOSABI_NONE, ABIVersion: (uint8) 0, ByteOrder: (binary.littleEndian) LittleEndian, Type: (elf.Type) ET_EXEC, Machine: (elf.Machine) EM_X86_64, Entry: (uint64) 4605856 }, Sections: ([]*elf.Section) (len=25 cap=25) { (*elf.Section)(0xc0000fe000)({ SectionHeader: (elf.SectionHeader) { Name: (string) "" ... }}), (*elf.Section)(0xc0000fe080)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=5) ".text", ... }}), (*elf.Section)(0xc0000fe100)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=7) ".rodata", ... }}), (*elf.Section)(0xc0000fe180)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=9) ".typelink", ... }}), (*elf.Section)(0xc0000fe200)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=9) ".itablink", ... }}), (*elf.Section)(0xc0000fe280)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=9) ".gosymtab", ... }}), (*elf.Section)(0xc0000fe300)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=10) ".gopclntab", ... }}), (*elf.Section)(0xc0000fe380)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=13) ".go.buildinfo", }}), (*elf.Section)(0xc0000fe400)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=10) ".noptrdata", ... }}), (*elf.Section)(0xc0000fe480)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=5) ".data", ... }}), (*elf.Section)(0xc0000fe500)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=4) ".bss", ... }}), (*elf.Section)(0xc0000fe580)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=9) ".noptrbss", ... }}), (*elf.Section)(0xc0000fe600)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=14) ".zdebug_abbrev", ... }}), (*elf.Section)(0xc0000fe680)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=12) ".zdebug_line", ... }}), (*elf.Section)(0xc0000fe700)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=13) ".zdebug_frame", ... }}), (*elf.Section)(0xc0000fe780)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=16) ".zdebug_pubnames", ... }}), (*elf.Section)(0xc0000fe800)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=16) ".zdebug_pubtypes", ... }}), (*elf.Section)(0xc0000fe880)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=18) ".debug_gdb_scripts", ... }}), (*elf.Section)(0xc0000fe900)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=12) ".zdebug_info", ... }}), (*elf.Section)(0xc0000fe980)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=11) ".zdebug_loc", ... }}), (*elf.Section)(0xc0000fea00)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=14) ".zdebug_ranges", ... }}), (*elf.Section)(0xc0000fea80)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=16) ".note.go.buildid", ... }}), (*elf.Section)(0xc0000feb00)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=7) ".symtab", ... }}), (*elf.Section)(0xc0000feb80)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=7) ".strtab", ... }}), (*elf.Section)(0xc0000fec00)({ SectionHeader: (elf.SectionHeader) { Name: (string) (len=9) ".shstrtab", ... }}) }, Progs: ([]*elf.Prog) (len=7 cap=7) { (*elf.Prog)(0xc0000ba2a0)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_PHDR, Flags: (elf.ProgFlag) PF_R, Vaddr: (uint64) 4194368 }}), (*elf.Prog)(0xc0000ba300)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_NOTE, Flags: (elf.ProgFlag) PF_R, Vaddr: (uint64) 4198300 }}), (*elf.Prog)(0xc0000ba360)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_LOAD, Flags: (elf.ProgFlag) PF_X+PF_R, Vaddr: (uint64) 4194304 }}), (*elf.Prog)(0xc0000ba3c0)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_LOAD, Flags: (elf.ProgFlag) PF_R, Vaddr: (uint64) 4825088 }}), (*elf.Prog)(0xc0000ba420)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_LOAD, Flags: (elf.ProgFlag) PF_W+PF_R, Vaddr: (uint64) 5500928 }}), (*elf.Prog)(0xc0000ba480)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_LOOS+74769745, Flags: (elf.ProgFlag) PF_W+PF_R, Vaddr: (uint64) 0 }}), (*elf.Prog)(0xc0000ba4e0)({ ProgHeader: (elf.ProgHeader) { Type: (elf.ProgType) PT_LOOS+84153728, Flags: (elf.ProgFlag) 0x2a00, Vaddr: (uint64) 0 }}) }, ... }) ``` #### 读取文件段头表 elf.File中的Progs字段,即为段头表(Program Header Table)。前面示例展示了如何读取ELF文件并打印其结构。在此基础上我们将继续对段头表数据一探究竟。 现在遍历ELF文件中段头表数据,查看每个段的类型、权限位、虚拟存储器地址、段大小,段中其他数据赞不关心。 ```go package main import ( "text/tabwriter" ... ) func main() { ... file, err := elf.Open(prog) ... tw := tabwriter.NewWriter(os.Stdout, 0, 4, 3, ' ', 0) defer tw.Flush() fmt.Fprintf(tw, "No.\tType\tFlags\tVAddr\tMemSize\n") for idx, p := range file.Progs { fmt.Fprintf(tw, "%d\t%v\t%v\t%#x\t%d\n", idx, p.Type, p.Flags, p.Vaddr, p.Memsz) } } ``` 运行测试`go run main.go ../testdata/loop`,程序输出如下。 我们可以看到各个段的索引编号、段类型、权限位、虚拟存储器地址、段占用内存大小(有的段大小可能大于待加载的数据量大小,如包含.data,.bss的段多出来的就可以给堆)。 ```bash No. Type Flags VAddr MemSize 0 PT_PHDR PF_R 0x400040 392 1 PT_NOTE PF_R 0x400f9c 100 2 PT_LOAD PF_X+PF_R 0x400000 626964 3 PT_LOAD PF_R 0x49a000 673559 4 PT_LOAD PF_W+PF_R 0x53f000 295048 5 PT_LOOS+74769745 PF_W+PF_R 0x0 0 6 PT_LOOS+84153728 0x2a00 0x0 0 ``` #### 读取文件节头表 只需要遍历file.Sections即可读取节头表信息,注意SectionHeader entry在当前pkg实现中被组织到了每一个elf.Section中。 ```go package main import ( "text/tabwriter" ... ) func main() { ... file, err := elf.Open(prog) ... tw = tabwriter.NewWriter(os.Stdout, 0, 4, 3, ' ', 0) heading := "No.\tName\tType\tFlags\tAddr\tOffset\tSize\tLink\tInfo\tAddrAlign\tEntSize\tFileSize\n" fmt.Fprintf(tw, heading) for idx, s := range file.Sections { fmt.Fprintf(tw, "%d\t%s\t%s\t%s\t%#x\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n", idx, s.Name, s.Type.String(), s.Flags.String(), s.Addr, s.Offset, s.Size, s.Link, s.Info, s.Addralign, s.Entsize, s.FileSize) } tw.Flush() } ``` 运行测试`go run main.go ../testdata/loop`,程序会输出如下节头表信息,从中我们可以看到各个section的编号、名称、类型、flags、虚拟地址、偏移量、大小、连接信息,等等。 ```bash No. Name Type Flags Addr Offset Size Link Info AddrAlign EntSize FileSize 0 SHT_NULL 0x0 0x0 0 0 0 0 0 0 0 1 .text SHT_PROGBITS SHF_ALLOC+SHF_EXECINSTR 0x401000 4096 622868 0 0 32 0 622868 2 .rodata SHT_PROGBITS SHF_ALLOC 0x49a000 630784 278566 0 0 32 0 278566 3 .typelink SHT_PROGBITS SHF_ALLOC 0x4de200 909824 1844 0 0 32 0 1844 4 .itablink SHT_PROGBITS SHF_ALLOC 0x4de938 911672 80 0 0 8 0 80 5 .gosymtab SHT_PROGBITS SHF_ALLOC 0x4de988 911752 0 0 0 1 0 0 6 .gopclntab SHT_PROGBITS SHF_ALLOC 0x4de9a0 911776 392567 0 0 32 0 392567 7 .go.buildinfo SHT_PROGBITS SHF_WRITE+SHF_ALLOC 0x53f000 1306624 32 0 0 16 0 32 8 .noptrdata SHT_PROGBITS SHF_WRITE+SHF_ALLOC 0x53f020 1306656 58560 0 0 32 0 58560 9 .data SHT_PROGBITS SHF_WRITE+SHF_ALLOC 0x54d4e0 1365216 29712 0 0 32 0 29712 10 .bss SHT_NOBITS SHF_WRITE+SHF_ALLOC 0x554900 1394928 196400 0 0 32 0 196400 11 .noptrbss SHT_NOBITS SHF_WRITE+SHF_ALLOC 0x584840 1394928 10312 0 0 32 0 10312 12 .zdebug_abbrev SHT_PROGBITS 0x0 0x588000 1394928 281 0 0 1 0 281 13 .zdebug_line SHT_PROGBITS 0x0 0x588119 1395209 117701 0 0 1 0 117701 14 .zdebug_frame SHT_PROGBITS 0x0 0x5a4cde 1512910 25178 0 0 1 0 25178 15 .zdebug_pubnames SHT_PROGBITS 0x0 0x5aaf38 1538088 5283 0 0 1 0 5283 16 .zdebug_pubtypes SHT_PROGBITS 0x0 0x5ac3db 1543371 13539 0 0 1 0 13539 17 .debug_gdb_scripts SHT_PROGBITS 0x0 0x5af8be 1556910 44 0 0 1 0 44 18 .zdebug_info SHT_PROGBITS 0x0 0x5af8ea 1556954 211236 0 0 1 0 211236 19 .zdebug_loc SHT_PROGBITS 0x0 0x5e320e 1768190 92521 0 0 1 0 92521 20 .zdebug_ranges SHT_PROGBITS 0x0 0x5f9b77 1860711 35995 0 0 1 0 35995 21 .note.go.buildid SHT_NOTE SHF_ALLOC 0x400f9c 3996 100 0 0 4 0 100 22 .symtab SHT_SYMTAB 0x0 0x0 1896712 70944 23 443 8 24 70944 23 .strtab SHT_STRTAB 0x0 0x0 1967656 67996 0 0 1 0 67996 24 .shstrtab SHT_STRTAB 0x0 0x0 2035652 280 0 0 1 0 280 ``` #### 读取指定section 现在我们看下如何读取指定的section的数据,以调试器过程中将使用到的section作为示例是一个不错的注意。读取prog的数据并无二致,本质上也是调用的section reader。 **示例1:.text section:** ```go package main import ( "text/tabwriter" ... ) func main() { ... file, err := elf.Open(prog) ... // .text section dat, err := file.Section(".text").Data() if err != nil { panic(err) } fmt.Printf("% x\n", dat[:32]) } ``` 运行测试`go run main.go ../testdata/loop`,程序会以16进制形式输出.text section的前32个bytes。 ```bash 64 48 8b 0c 25 f8 ff ff ff 48 3b 61 10 76 38 48 83 ec 18 48 89 6c 24 10 48 8d 6c 24 10 0f 1f 00 ``` 只是查看一堆16进制数,并没有什么特别大帮助,对于.text节,我们还可以调用反汇编框架将这些指令转换为汇编语言。下面的程序将反汇编前10条指令数据并输出。 ```go import ( ""golang.org/x/arch/x86/x86asm"" ) func main() { ... // .text section dat, err := file.Section(".text").Data() ... offset := 0 for i := 0; i < 10; i++ { inst, err := x86asm.Decode(dat[offset:], 64) if err != nil { break } fmt.Println(x86asm.GNUSyntax(inst, 0, nil)) offset += inst.Len } } ``` **示例2:.data section:** 按照相同的方法,我们可以读取.data section的数据,但是下面的程序同样只能打印16进制数,这并没有太大帮助。联想到.text section可以通过反汇编框架进行反汇编(指令编解码是有规律的),我们如何解析这里的数据呢? 这就要用到对go程序类型系统的理解了,比如.data中存储的一个string,或者一个struct,或者一个interface{},只有对类型系统有了深入的理解,我们才能正确解释这里的数据,并对我们的调试过程提供帮助。 ```go func main() { ... dat, err := file.Section(".data").Data() if err != nil { panic(err) } fmt.Printf("% x\n", dat[:32]) } ``` 直接读写内存数据的场景,往往是我们知道了一个变量的内存地址,既然是变量当然也知道其类型,然后我们再查看并解析该内存地址处的数据,如pmem命令的使用。pmem需要我们手动指定每个元素字节大小才能正确解析。 更方便的做法是借助调试符号信息,分析这个符号对应的类型信息,以及在内存中的位置,然后我们再读取内存数据并按照类型进行解析。我们将在debug/dwarf一节开始介绍。 本节内容我们介绍了标准库debug/elf的设计并演示了常用操作,我们接下来介绍下debug/gosym包的使用,了解下如何利用go工具链生成的符号、行号信息。 ### 参考内容 1. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools 2. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub 3. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes 4. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view 5. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk ================================================ FILE: book/7-headto-sym-debugger/6-gopkg-debug/2-gosym.md ================================================ ## pkg debug/gosym 应用 ### 数据类型及关系 标准库提供了package `debug/gosym` 来读取go工具链为go语言生成的一些特有的section数据,如.gosymtab、.gopclntab。其为go语言运行时提供了一种高效快速的计算调用栈的方法,这在go语言出现panic希望打印堆栈信息的时候非常有帮助。 package debug/gosym中的相关重要数据结构,如下图所示: ![gopkg debug/gosym](assets/1c07e57ff316dda1.png) 关于go定制的.gosymtab、.gopclntab相关的符号信息设计,可以参考 [Go 1.2 Runtime Symbol Information](https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub),整体来看,比较重要的就是“**Table**”这个数据结构,注意到它有几个非常实用的导出方法,我们可以用来**在指令地址与源文件位置之前进行快速的转换**,借此可以在运行时回溯调用栈Caller PC值的基础上,查询这个表,就可以实现一个获得当前的调用栈。.gosymtab、.gopclntab的主要目的也是在此。 ### go定制的sections ELF文件中符号表信息一般会存储在 `.symtab` section中,go程序有点特殊在go1.2及之前的版本有一个特殊的.gosymtab,其中存储了接近plan9风格的符号表结构信息,但是在go1.3之后,.gosymtab不再包含任何符号信息。 另外,ELF文件存储调试用的行号表、调用栈信息,如果是DWARF调试信息格式的话,一版是存储在.[z]debug_line、.[z]debug_frame中。go程序比较特殊,为了使程序在运行时可以可靠地跟踪调用栈,go编译工具链生成了一个名为 `.gopclntab`的section,其中保存了go程序的行号表信息。 那么,go为什么不使用.[z]debug_line、.[z]debug_frame sections呢?为什么要独立添加一个.gosymtab、.gopclntab呢?这几个sections有什么区别呢? - 我们确定的是.[z]debug_前缀开头的sections中包含的是调试信息,是给调试器等使用的,.gosymtab、.gopclntab则是给go运行时使用的。 - go程序执行时,其运行时部分会加载.gosymtab、.gopclntab的数据到进程内存中,用来执行栈跟踪(stack tracebacks),比如runtime.Callers。但是.symtab、.[z]debug_\* sections并没有被加载到内存中,它是由外部调试器来读取并加载的,如gdb、delve。 ```bash $ readelf -l Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR ... NOTE ... LOAD ...// 02 .note.go.builid LOAD ...// 03 .rodata ... .gosymtab .gopclntab LOAD ...// 04 .go.buildinfo ... GNU_STACK ... LOOS+5041580 ... Section to Segment mapping: Segment Sections... 00 01 .note.go.buildid 02 .text .note.go.buildid 03 .rodata .typelink .itablink .gosymtab .gopclntab 04 .go.buildinfo .noptrdata .data .bss .noptrbss 05 06 ``` 对一个构建好的go程序执行命令 `readelf -l `我们可以看到段索引02、03、04位LOAD类型表示是要加载到内存中的,这个段对应的sections也显示包含.gosymtab、.gopclntab但是不包含.[z]debug_\*相关的sections。 这既符合常见编程语言、工具链的惯例,也是为了更高效地在指令地址、源码行之间做转换,后面会介绍go是如何做转换的。 其实,go早期的核心开发者,它们多出自Bell实验室,很多有Plan9的工作经验,在研发Plan9时就已经有了类似pclntab的尝试,从Plan9的man手册中可以查看到相关的信息。 **Plan9's man a.out** ```bash NAME a.out - object file format SYNOPSIS #include DESCRIPTION An executable Plan 9 binary file has up to six sections: a header, the program text, the data, a symbol table, a PC/SP offset table (MC68020 only), and finally a PC/line number table. The header, given by a structure in , con- tains 4-byte integers in big-endian order: .... A similar table, occupying pcsz-bytes, is the next section in an executable; it is present for all architectures. The same algorithm may be run using this table to recover the absolute source line number from a given program location. The absolute line number (starting from zero) counts the newlines in the C-preprocessed source seen by the compiler. Three symbol types in the main symbol table facilitate con- version of the absolute number to source file and line num- ber: ``` go程序的很多核心开发者本身就是Plan9的开发者,go中借鉴Plan9的经验也就不足为奇了,早期pclntab的存储结构与plan9下程序的pclntab很接近,但是现在已经差别很大了,可以参考go1.2 pclntab的设计proposal:[Go 1.2 Runtime Symbol Information](https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub)。 > 注:另外提一下,程序中涉及到cgo的部分,是没有办法通过.gosymtab、.gopclntab的方式来跟踪其调用栈的。 通过package `debug/gosym`可以构建出pcln table,通过其方法PcToLine、LineToPc等,可以帮助我们快速查询指令地址与源文件中位置的关系,也可以通过它来进一步分析调用栈,如程序panic时我们希望打印调用栈来定位出错的位置。 **对调用栈信息的支持才是.gosymtab、.gopclntab所主要解决的问题**,go1.3之后调用栈数据应该是完全由.gopclntab支持了,所以.gosymtab也就为空了。和调试器需要的.[z]debug_line、.[z]debug_frame等在设计目的上有着很大区别,其中.[z]debug_frame不仅可以追踪调用栈信息,也可以追踪每一个栈帧中的寄存器数据的变化,其数据编码、解析、运算逻辑也更加复杂。 那.gosymtab、.gopclntab能否用于调试器呢?也不能说完全没用,只是这里面的数据相对DWARF调试信息来说,缺失了一些调试需要的信息,我们还是需要用到DWARF才能完整解决调试场景中的问题。 现在我们应该清楚package debug/gosym以及对应.gosymtab、.gopclntab sections的用途了,也应该清楚与.symtab以及调试相关的.[z]debug_\*这些sections的区别了。 ### 常用操作及示例 这是我们的一个测试程序 testdata/loop2.go,我们先展示下其源文件信息,接下来执行 `go build -gcflags="all=-N -l" -o loop2 loop2.go`将其编译成可执行程序loop2,后面我们读取loop2并继续做实验。 #### PC与源文件互转 **testdata/loop2.go:** ```go 1 package main 2 3 import ( 4 "fmt" 5 "os" 6 "time" 7 ) 8 9 func init() { 10 go func() { 11 for { 12 fmt.Println("main.func1 pid:", os.Getpid()) 13 time.Sleep(time.Second) 14 } 15 }() 16 } 17 func main() { 18 for { 19 fmt.Println("main.main pid:", os.Getpid()) 20 time.Sleep(time.Second * 3) 21 } 22 } ``` 下面我们通过 `debug/gosym`来写个测试程序,目标是实现虚拟内存地址pc和源文件位置、函数之间的转换。 **main.go:** ````go package main import ( "debug/elf" "debug/gosym" ) func main() { if len(os.Args) != 2 { fmt.Fprintln(os.Stderr, "usage: go run main.go ") os.Exit(1) } prog := os.Args[1] // open elf file, err := elf.Open(prog) if err != nil { panic(err) } gosymtab, _ := file.Section(".gosymtab").Data() gopclntab, _ := file.Section(".gopclntab").Data() pclntab := gosym.NewLineTable(gopclntab, file.Section(".text").Addr) table, _ := gosym.NewTable(gosymtab, pclntab) // table.LineToPC(line, num), here `line` must be absolute path pc, fn, err := table.LineToPC("/root/debugger101/testdata/loop2.go", 3) if err != nil { fmt.Println(err) } else { fmt.Printf("pc => %#x\tfn => %s\n", pc, fn.Name) } pc, fn, _ = table.LineToPC("/path-to/testdata/loop2.go", 9) fmt.Printf("pc => %#x\tfn => %s\n", pc, fn.Name) pc, fn, _ = table.LineToPC("/path-to/testdata/loop2.go", 11) fmt.Printf("pc => %#x\tfn => %s\n", pc, fn.Name) pc, fn, _ = table.LineToPC("/path-to/testdata/loop2.go", 17) fmt.Printf("pc => %#x\tfn => %s\n", pc, fn.Name) // here 0x4b86cf is hardcoded, it's the address of loop2.go:9 f, l, fn := table.PCToLine(0x4b86cf) fmt.Printf("pc => %#x\tfn => %s\tpos => %s:%d\n", 0x4b86cf, fn.Name, f, l) } ```` 运行测试 `go run main.go ../testdata/loop2`,注意以上程序中指定源文件时使用了绝对路径,我们将得到如下输出: ```bash $ go run main.go ../testdata/loop2 no code at /root/debugger101/testdata/loop2.go:3 pc => 0x4b86cf fn => main.init.0.func1 pc => 0x4b8791 fn => main.init.0.func1 pc => 0x4b85b1 fn => main.main pc => 0x4b86cf fn => main.init.0.func1 pos => /root/debugger101/testdata/loop2.go:9 ``` 在上述测试程序中,我们一开始指定了一个源文件位置loop2.go:3的位置,查看源码可知,这个位置处是一些import声明,没有函数,所以这里找不到对应的指令,所以才会返回错误信息“no code at ....loop2.go:3”。剩余几行测试都指定了有效的源码位置,分别输出了几个源文件位置对应的指令地址。 然后我们从输出的结果中选择第一个测试case loop2.go:9的pc值0x4b86cf作为table.PCToLine(...)的参数来测试从pc转换为源文件位置,程序正确输出了源文件位置。 #### 运行时栈跟踪 go程序除了通过error来传播错误,还有一种方式是通过panic来传播异常,由于panic传播路径可能会比较长,直到它被当前goroutine recover或者进程crash。 当出现panic时,如果我们主动recover了,也会希望通过打印调用栈来追踪问题的源头;如果没有recover导致进程crash了,那么运行时也会打印每个goroutine的调用栈信息。两种方式的目的都是为了帮助我们容易定位panic的源头位置。 下面是一个演示go程序panic时recover并打印调用栈信息的示例: **main.go** ```go 1 package main 2 3 import ( 4 "runtime/debug" 5 ) 6 7 func main() { 8 defer func() { 9 if e := recover(); e != nil { 10 debug.PrintStack() 11 } 12 }() 13 f1() 14 } 15 16 func f1() { 17 f2() 18 } 19 20 func f2() { 21 panic("let's panic") 22 } ``` 运行 `go run main.go`进行测试: ```bash $ go run main.go goroutine 1 [running]: runtime/debug.Stack(0xc00001408b, 0x8, 0xc000096df0) /usr/local/go/src/runtime/debug/stack.go:24 +0x9f runtime/debug.PrintStack() /usr/local/go/src/runtime/debug/stack.go:16 +0x25 main.main.func1() /Users/zhangjie/main.go:10 +0x45 panic(0x1084480, 0x10aff40) /usr/local/go/src/runtime/panic.go:969 +0x175 main.f2(...) /Users/zhangjie/main.go:21 main.f1(...) /Users/zhangjie/main.go:17 main.main() /Users/zhangjie/main.go:13 +0x5d ``` 上述调用栈信息如何看呢: - 首先从上往下看,runtime/debug.Stack->runtime/debug.PrintStack->main.main.func1,这里是panic被recover的位置; - 继续往下看,可以看到panic在何处生成的,panic->main.f2,注意到这个函数第21行调用了panic方法,找到panic发生的位置了; - 调用栈剩下的就没有必要看了; 前面我们不止一次提到**go运行时调用栈信息是基于.gopclntab构建出来的**,but how? #### 栈跟踪如何实现 ##### 实现栈跟踪的常见方法 栈跟踪(stack unwinding)如何实现呢,我们先来说下一般性的思路: - 首先获取当前程序的pc信息,pc确定了就可以确定当前函数f对应的栈帧; - 再获取当前程序的bp信息(go里面称为fp),进而可以拿到函数返回地址; - 返回地址往往就是在caller对应的函数的栈帧中了,将该返回地址作为新的pc; - 重复前面几步,直到栈跟踪深度达到要求为止; ##### go runtime利用.gopclntab实现栈跟踪 要理解这点首先应该理解.gopclntab的设计,这部分内容可以参考[Go 1.2 Runtime Symbol Information](https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub),然后我们可以看下实现,即gosym.Table及相关类型的结构。 在本文开头我们已经展示了package debug/gosym的设计,从中我们可以看到Table表结构包含了很多Syms、Funcs、Files、Objs,进一步结合其暴露的Methods不难看出,我们可以轻松地在pc、file、lineno、func之间进行转换。 考虑下调用栈是什么,调用栈是一系列方法调用的caller-callee关系,这个Table里面可没有,它只是用来辅助查询的。 - 如果要获得调用栈,首先你要能拿到goroutine当前的pc值,这个go runtime肯定可以拿到,有了pc我们就可以通过gosym.Table找到当前函数名; - 然后我们需要知道当前函数调用的返回地址,那就需要通过go runtime获得bp(go里面称之为fp),通过它找到存放返回地址的位置,拿到返回地址; - 返回地址绝大多数情况下都是返回到caller对应的函数调用中(除非尾递归优化不返回,但是go编译器不支持尾递归优化,所以忽略),将这个返回地址作为pc,去gosym.Table中找对应的函数定义,这样就确定了一个caller; - 重复上述过程即可,直到符合栈跟踪的深度要求。 go标准库 `runtime/debug.PrintStack()`就是这么实现的,只是它考虑的更周全,比如打印所有goroutine的调用栈时需要STW,调用栈信息过大可能超出goroutine栈上限,所以会先切到systemstack再生成调用栈信息,会考虑对gc的影响,等等。 ##### 调试器利用.gopclntab+FDE实现栈跟踪 根据前面对gosym.Table的分析,我们很容易明白,如果只是单纯利用.gopclntab来实现stack unwinding,那是不可能的,至少还要知道pc、bp信息。而且调试器里面也很难像go runtime那样灵活自如地对goroutine进行控制,如获取goroutine的各种上下文信息。 **那调试器应该如何做呢?** **研究delve源码发现,在[go-delve/delve@913153e7](https://sourcegraph.com/github.com/go-delve/delve@913153e7ffb62512ccdf850bc37bf3abd3aecc2b/-/blob/pkg/proc/stack.go?subtree=true#L115)及之前的版本中是借助gosym.Table结合DWARF FDE实现的**: - dlv首先利用DWARF .debug_frame section来构建FDE列表; - dlv获得tracee的pc值,然后遍历FDE列表,找到FDE地址范围覆盖pc的FDE,这个FDE就是pc对应的函数栈帧了; - 然后再找caller,此时dlv再获取bp值,再计算出返回地址位置,再从该位置读取返回地址,然后再去遍历FDE列表找地址范围覆盖这个返回地址的FDE,这个FDE对应的就是caller; - 重复以上过程即可,直到符合栈跟踪深度要求; **找caller-callee关系,dlv就是按上述过程处理的,至于callee当前pc以及caller调用当前函数处的pc,这些虚拟内存地址对应的函数名、源文件位置信息,还是通过gosym.Table来转换实现的。** **其实这里go-delve/delve的实现走了一点“捷径”,本来它可以通过.[z]debug_line来实现pc和file:lineno的转换,也可以通过.[z]debug_frame来确定调用栈。** 这里需要明确的是,**.gopclntab只记录了纯go程序的pc、源文件位置映射信息,对于cgo程序的部分是不包含的,因为c编译器都不理解有这些东西的,因此有一定的局限性。**但是生成.[z]debug_line、.[z]debug_frame信息,常见编译器都是支持的,是可以更好地解决这里的局限性问题的。 [go-delve/delve@6d405179](https://sourcegraph.com/github.com/go-delve/delve@6d405179/-/blob/pkg/proc/stack.go?subtree=true#L113),项目核心开发aarzilli解决了这个问题,并在提交记录里特别强调了用.[z]debug_line来代替.gosymtab、.gopclntab这个问题: ```bash commit 6d40517944d40113469b385784f47efa4a25080d Author: aarzilli Date: Fri Sep 1 15:30:45 2017 +0200 proc: replace all uses of gosymtab/gopclntab with uses of debug_line gosymtab and gopclntab only contain informations about go code, linked C code isn't there, we should use debug_line instead to also cover C. Updates #935 ``` ok,这里大家应该明白实现原理了,我们将在下一章调试器开发过程中加以实践。 ### 本节小结 本节介绍了标准库debug/gosym包的设计,并演示了如何在指令地址和源代码位置之间进行转换。也介绍了标准库debug.PrintStack()如何基于.gosymtab、.gopclntab实现运行时调用栈跟踪。也介绍了大名鼎鼎的delve调试器早期如何利用.gosymtab、.gopclntab + DWARF FDE实现栈跟踪,以及后续为了兼容cgo又如何迁移到DWARF .[z]debug_line、.[z]debug_frame来更全面地支持调试。 大家对于这部分内容已经有些了解,接下来我们将开始接触调试信息标准DWARF。 ### 参考内容 1. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools 2. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub 3. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes 4. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view 5. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk ================================================ FILE: book/7-headto-sym-debugger/6-gopkg-debug/3-dwarf.md ================================================ ## pkg debug/dwarf 应用 ### DWARF数据存储 标准库提供了package `debug/dwarf` 来读取go编译工具链生成的DWARF数据,比如.debug_info、.debug_line等。 go生成DWARF调试信息时,会对DWARF信息进行压缩再存储到不同的section中。比如描述types、variables、function定义的数据,开启压缩前是存储在.debug_info中,开启压缩后则被存储到.zdebug_info中,通常采用的是zlib压缩算法。在早期版本的 `go-delve/delve` 实现中就是这么做的,但是实际情况是,ELF section中有个标识字段 `Compressed` 来表示Section中的数据是否开启了压缩,在go新版本中,压缩后的调试信息也不会再写到 .zdebug_ 相关sections了,而是统一写入 .debug_ sections中,同时设置标识位 `Compressed=true`。 编译构建go程序时可以指定链接器选项 `go build -ldflags="dwarfcompress=false"`来禁用dwarf数据压缩,有些DWARF信息查看的工具比较陈旧,不支持解压缩,此时可以考虑关闭dwarf数据压缩。`debug/dwarf`有提供了DWARF信息读取的能力,并且对上述这些过去的设计实现有做兼容处理。美中不足的是,`debug/dwarf`**未提供调用栈信息的读取**,这部分功能需要自行实现。 ### 数据类型及关系 package `debug/dwarf`中的相关重要数据结构,如下图所示: ![image-20201206022523363](assets/image-20201206022523363.png) 当我们打开了一个elf.File之后,便可以读取DWARF数据,当我们调用 `elf.File.Data()`时便可以返回读取、解析后的DWARF数据(即类图中Data),接下来便是在此基础上进一步读取DWARF中的各类信息,以及与对源码的理解结合起来。 通过Data可以获取一个reader,该reader能够读取并解析.[z]debug_info section的数据,通过这个reader可以遍历DIE(即类图中Entry),每个DIE都由一个Tag和一系列Attr构成。 当我们读取到一个Tag类型为DW_TAG_compile_unit的DIE时,表明当前是一个编译单元,每个编译单元都有一个自己的行号表,通过Data即该DIE,可以得到一个读取.[z]debug_line的LineReader,通过它可以读取行号表中的记录(即类图中LineEntry),它记录了虚拟内存地址、源文件名、行号、列号等的一些对应关系。 ### 常用操作及示例 前面大致介绍了标准库提供的支持、局限性,以及标准库的大致使用方式,接下来我们提供几个示例来演示如何读取并解析DWARF调试信息,如何从中提取我们关心的内容。 #### 读取DWARF数据 首先要打开elf文件,然后再读取DWARF相关的多个section数据并解析,go标准库已经帮我们实现了DWARF数据是否压缩、是否需要解压缩的问题。 下面的程序打开一个elf文件并返回解析后的DWARF数据: ```go import ( "debug/elf" "fmt" ) func main() { if len(os.Args) != 2 { fmt.Fprintln(os.Stderr, "usage: go run main.go ") os.Exit(1) } prog := os.Args[1] // open elf file, err := elf.Open(prog) if err != nil { panic(err) } // dwarf调试信息遍历 dw, err := file.DWARF() if err != nil { panic(err) } fmt.Println("read dwarf ok") } ``` 运行测试 `go run main.go ../testdata/loop2`,程序只是简单地打印一行读取成功的信息,在此基础上我们将实现DWARF数据中各类信息的读取。 #### 读取编译单元信息 当从elf文件拿到DWARF数据dwarf.Data之后,就可以从dwarf.Data中读取感兴趣的数据。在读取之前要理解DWARF数据的组织方式,以及go标准库实现的一点内容。 工程中可能包含了多个源文件,每个源文件是一个编译单元,一个目标文件中可能包含了多个编译单元。生成调试信息时每一个目标文件对应一个tag类型为DW_TAG_compile_unit的DIE。该DIE的childrens又包含了其他丰富的信息,如函数、函数中的变量等,这些信息都是通过DWARF DIE来表述的。 > go编译单元是如何产生的,go tool compile \*.go,每个源文件是一个编译单元?每个源文件编译完后对应着一个目标文件?联想下C和C++,每个源文件是一个独立的编译单元,每个源文件对应着一个目标文件。这点上go有些差异,大家在跑下面测试的时候可以看出来。 > > // A CompilationUnit represents a set of source files that are compiled > // together. Since all Go sources in a Go package are compiled together, > // there's one CompilationUnit per package that represents all Go sources in > // that package, plus one for each assembly file. > // > // Equivalently, there's one CompilationUnit per object file in each Library > // loaded by the linker. > // > // These are used for both DWARF and pclntab generation. > type CompilationUnit struct { > Lib *Library // Our library > PclnIndex int // Index of this CU in pclntab > PCs []dwarf.Range // PC ranges, relative to Textp[0] > DWInfo *dwarf.DWDie // CU root DIE > FileTable []string // The file table used in this compilation unit. > > Consts LoaderSym // Package constants DIEs > FuncDIEs []LoaderSym // Function DIE subtrees > VarDIEs []LoaderSym // Global variable DIEs > AbsFnDIEs []LoaderSym // Abstract function DIE subtrees > RangeSyms []LoaderSym // Symbols for debug_range > Textp []LoaderSym // Text symbols in this CU > } > > go中是按照package来组织的,一个包对应着一个编译单元,如果有汇编文件,每个汇编文件单独作为一个编译单元,每个编译单元对应着一个目标文件。 `rd := dwarf.Data.Reader()`会返回一个reader对象,通过 `rd.Next()`能够让我们遍历ELF文件中所有的DIE,因为所有的编译单元、类型、变量、函数这些都是通过DIE来表示的,我们也就具备了遍历ELF文件中所有编译单元及编译单元中定义的类型、变量、函数的能力。 下面我们先尝试遍历所有的编译单元信息。 ```go package main import ( "debug/dwarf" "debug/elf" "fmt" "os" "text/tabwriter" ) func main() { ... dw, err := file.DWARF() ... err = iterateComplilationUnit(dw) if err != nil { fmt.Println(err) } } func iterateComplilationUnit(dw *dwarf.Data) error { rd := dw.Reader() tw := tabwriter.NewWriter(os.Stdout, 0, 4, 3, ' ', 0) fmt.Fprintf(tw, "No.\tTag\tName\tLanguage\tStmtList\tLowPC\tRanges\tOthers\n") defer tw.Flush() for idx := 0; ; idx++ { entry, err := rd.Next() if err != nil { return fmt.Errorf("iterate entry error: %v", err) } if entry == nil { fmt.Println("iterate entry finished") return nil } if entry.Tag != dwarf.TagCompileUnit { continue } fmt.Fprintf(tw, "%d\t%s\t%v\t%v\t%v\t%v\t%v\n", idx, entry.Tag.String(), entry.Field[0].Val, entry.Field[1].Val, entry.Field[2].Val, entry.Field[3].Val, entry.Field[4].Val, ) } } ``` 执行测试 `go run main.go ../testdata/loop2`,程序输出了如下信息: ```bash $ go run main.go ../testdata/loop2 Tag Name Language StmtList LowPC Ranges Others CompileUnit sync 22 0 4724928 0 CompileUnit internal/cpu 22 3626 4198400 32 CompileUnit internal/cpu 22 4715 4201888 80 CompileUnit runtime/internal/sys 22 4846 4202336 112 CompileUnit fmt 22 5513 4906048 144 CompileUnit runtime/internal/atomic 22 14330 4202560 176 CompileUnit strconv 22 160219 4653184 944 ........... ....... .. ...... ....... ... CompileUnit syscall 22 167358 4883104 992 CompileUnit internal/oserror 22 170142 4882624 1040 CompileUnit io 22 170356 4881888 1072 CompileUnit internal/fmtsort 22 170746 4873280 1104 CompileUnit sort 22 171968 4870400 1136 // <= 1个CU,但路径下有多个go文件 CompileUnit unicode/utf8 22 172957 4676128 1168 CompileUnit reflect 22 174048 4767616 1200 CompileUnit sync/atomic 22 194816 4658240 1248 CompileUnit sync/atomic 22 195127 4658976 1280 CompileUnit unicode 22 195267 4742624 1312 CompileUnit runtime 22 195635 4631616 1344 CompileUnit reflect 22 195725 4855840 1376 ``` 这里显示了每个编译单元的信息,如名称、编程语言(22为go语言)、语句列表数量、地址范围。 #### 读取函数定义 DIE描述代码,前面提到了编译单元是tag为DW_TAG_compile_unit的DIE来描述的,读取完该DIE之后,可继续读取编译单元中的函数定义,即tag为DW_TAG_subprogram的一系列DIE。读取了每个函数的同时,函数内部又包含一些局部变量定义等,即tag为DW_TAG_variable的一系列DIE。 它们之间的关系,大致如下所示: ```bash DW_TAG_compile_unit ... DW_TAG_subprogram ... DW_TAG_variable DW_AT_name: "a" DW_AT_type: (signature) 0xd681845c 21a14576 DW_AT_location: ... ... ``` 这里我们以读取main.main为例,演示下如何读取编译单元中的函数、变量信息。 **main.go** ```go package main import ( "debug/dwarf" "debug/elf" "fmt" "os" ) func main() { ... dw, err := file.DWARF() ... err = parseDwarf(dw) if err != nil { fmt.Println(err) } } // Variable 函数局部变量信息 type Variable struct { Name string } // Function 函数信息,包括函数名、定义的源文件、包含的变量 type Function struct { Name string DeclFile string Variables []*Variable } // CompileUnit 编译单元,包括一系列源文件、函数定义 type CompileUnit struct { Source []string Funcs []*Function } var compileUnits = []*CompileUnit{} func parseDwarf(dw *dwarf.Data) error { rd := dw.Reader() var curCompileUnit *CompileUnit var curFunction *Function for idx := 0; ; idx++ { entry, err := rd.Next() if err != nil { return fmt.Errorf("iterate entry error: %v", err) } if entry == nil { return nil } // parse compilation unit if entry.Tag == dwarf.TagCompileUnit { lrd, err := dw.LineReader(entry) if err != nil { return err } cu := &CompileUnit{} curCompileUnit = cu // record the files contained in this compilation unit for _, v := range lrd.Files() { if v == nil { continue } cu.Source = append(cu.Source, v.Name) } compileUnits = append(compileUnits, cu) } // pare subprogram if entry.Tag == dwarf.TagSubprogram { fn := &Function{ Name: entry.Val(dwarf.AttrName).(string), DeclFile: curCompileUnit.Source[entry.Val(dwarf.AttrDeclFile).(int64)-1], } curFunction = fn curCompileUnit.Funcs = append(curCompileUnit.Funcs, fn) // 如果是main.main函数,打印一下entry,方便我们印证 if fn.Name == "main.main" { printEntry(entry) fmt.Printf("main.main is defined in %s\n", fn.DeclFile) } } // parse variable if entry.Tag == dwarf.TagVariable { variable := &Variable{ Name: entry.Val(dwarf.AttrName).(string), } curFunction.Variables = append(curFunction.Variables, variable) // 如果当前变量定义在main.main中,打印一下entry,方便我们印证 if curFunction.Name == "main.main" { printEntry(entry) } } } return nil } // 打印每个DIE的详细信息,调试使用,方便我们根据具体结构编写代码 func printEntry(entry *dwarf.Entry) { fmt.Println("children:", entry.Children) fmt.Println("offset:", entry.Offset) fmt.Println("tag:", entry.Tag.String()) for _, f := range entry.Field { fmt.Println("attr:", f.Attr, f.Val, f.Class) } } ``` 在执行测试之前,我们也说一下用来测试的源程序,注意我们在main.main中定义了一个变量pid。 **testdata/loop2.go** ```go 1 package main 2 3 import "fmt" 4 import "os" 5 import "time" 6 7 func init() { .... 14 } 15 func main() { 16 pid := os.Getpid() 17 for { 18 fmt.Println("main.main pid:", pid) 19 time.Sleep(time.Second * 3) 20 } 21 } ``` 执行测试 `go run main.go ../testdata/loop2`,程序输出如下信息: ```bash $ go run main.go ../testdata/loop2 children: true offset: 324423 tag: Subprogram attr: Name main.main ClassString attr: Lowpc 4949376 ClassAddress attr: Highpc 4949656 ClassAddress attr: FrameBase [156] ClassExprLoc attr: DeclFile 2 ClassConstant attr: External true ClassFlag main.main is defined in /root/debugger101/testdata/loop2.go children: false offset: 324457 tag: Variable attr: Name pid ClassString attr: DeclLine 16 ClassConstant attr: Type 221723 ClassReference attr: Location [145 160 127] ClassExprLoc ``` 上面程序中打印了main.main对应的subprogram的详细信息,并展示了main.main是定义在testdata/loop2.go这个源文件中(行信息依赖行表,稍后介绍),还展示了main.main中定义的局部变量pid。 遍历编译单元CompileUnit,并从编译单元中依次读取各个函数Subprogram,以及函数中定义的一系列变量Variable的过程,大致可以由上述示例所覆盖。当然我们还要提取更多信息,比如函数定义在源文件中的行号信息、变量在源文件中的行号、列号信息等等。 #### 读取行号表信息 每个编译单元CompileUnit都有自己的行号表信息,当我们从DWARF数据中读取出一个tag类型为DW_TAG_compile_unit的DIE时,就可以尝试去行表.[z]debug_line中读取行号表信息了。这里debug/dwarf也提供了对应的实现,dwarf.LineReader每次从指定编译单元中读取一行行表信息dwarf.LineEntry。 后续基于行表数据可以轻松实现源文件位置和虚拟地址之间的转换。 我们先实现行号表的读取,只需在此前代码基础上做少许变更即可: ```go func main() { ... err = parseDwarf(dw) ... pc, err := find("/root/debugger101/testdata/loop2.go", 16) if err != nil { panic(err) } fmt.Printf("found pc: %#x\n", pc) } type CompileUnit struct { Source []string Funcs []*Function Lines []*dwarf.LineEntry } func parseDwarf(dw *dwarf.Data) error {} ... for idx := 0; ; idx++ { ... if entry.Tag == dwarf.TagCompileUnit { lrd, err := dw.LineReader(entry) ... for { var e dwarf.LineEntry err := lrd.Next(&e) if err == io.EOF { break } if err != nil { return err } curCompileUnit.Lines = append(curCompileUnit.Lines, &e) } } ... } } func find(file string, lineno int) (pc uint64, err error) { for _, cu := range compileUnits { for _, e := range cu.Lines { if e.File.Name != file { continue } if e.Line != lineno { continue } if !e.IsStmt { continue } return e.Address, nil } } return 0, errors.New("not found") } ``` 我们查找下源文件位置 `testdata/loop2.go:16`对应的虚拟地址(当前我们是硬编码的此位置),执行测试 `go run main.go ../testdata/loop2`: ```bash $ go run main.go ../testdata/loop2 found pc: 0x4b85af ``` 程序正确找到了上述源文件位置对应的虚拟内存地址。 读者朋友可能想问,为什么示例程序中不显示出源文件位置对应的函数定义呢?这里涉及到对.[z]debug_frame调用栈信息表的读取、解析,有了这部分信息才能构建FDE (Frame Descriptor Entry),才能得到指令的虚拟内存地址所在的Frame,进一步才能从Frame中获取到此栈帧对应的函数名。 很遗憾go标准库不支持对这些.debug_frame等部分sections的解析,我们需要自己实现。 #### 读取调用栈信息 elf文件中,调用栈信息表存储在.[z]debug_frame section中,go标准库 `debug/dwarf`不支持这部分信息的解析。我们将在后续章节中解释如何读取、解析、应用调用栈信息。 获取当前调用栈对调试而言是非常重要的,这里大家先了解这么个事情,我们后面再一起看。 ### 本节小结 本节介绍了go标准库debug/dwarf的设计以及应用,举了几个读取DWARF数据并解析编译单元、函数定义、变量、行号表信息相关的示例。 本小节中也首次抛出了很多DWARF相关的专业术语,读者可能未完全理解。本小节内容作为go标准库debug/*的一部分,故在此统一进行了介绍,期间穿插DWARF相关的知识不可避免,但是概念却未在此之前详细展开(主要篇幅原因一个小节中展开不现实),读者不理解实属正常,先掌握基本用法即可。 我们将在接下来第8章详细介绍DWARF调试信息标准,搞明白DWARF调试信息标准,这是胜任符号级调试器开发的并经之路。 ### 参考内容 1. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools 2. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub 3. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes 4. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view 5. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk ================================================ FILE: book/7-headto-sym-debugger/6-gopkg-debug/README.md ================================================ ## go标准库 : debug/* ### 简要回顾 前面我们介绍了ELF文件头、段头表(program header table)、节头表(section header table)、常见的节(sections)的结构和作用,我们也介绍了符号、符号表(.symtab)、字符串表(.strtab)的结构和作用。在此基础上,我们没有向困难的链接、重定位、加载细节低头,旁征博引、参考了众多技术资料,为大家系统性地梳理了链接、重定位、加载器的细节。 我坚信介绍清楚这些信息,加深大家的认识,将有助于我们开发调试器过程中少走些弯路。 至此,相信读者朋友们对ELF文件没有很多畏惧心理了。我们讲这么多,一方面是为了解决大家的疑虑、加深认识,另一方面也是为了建立大家对二进制分析、调试器开发的信心。而且相比之下,我更看重信心的建立,我们如同披挂上阵的战士,信心满满,即将出征。 ### go标准库 我们是基于go语言开发一款面向go程序的调试器,在我们介绍了很多系统原理、技术细节、其他语言示例之后,我们最终将回到如何通过go语言来落地的问题上。 首当其冲的就是如何解析ELF文件的问题,包括如何解析ELF文件头,ELF中的段头表、节头表,以及一些常见section中数据的解析,如.debug\_\* sections中的调试信息。 go标准库中提供了一些类似的工具,帮我们简化上述任务,下面就来看下。 go标准库package `debug/*`,专门用来读取、解析go编译工具链生成的ELF文件信息: - `debug/elf`支持ELF文件的读取、解析,提供了方法来根据名称定位section; - `debug/gosym`支持.gosymtab符号表、.gopclntab行号表的解析。设计上.gopclntab中通过pcsp记录了pc值对应的栈帧大小,所以很容易定位返回地址,可进一步确定caller,重复该过程可跟踪goroutine调用栈信息,如panic时打印的stacktrace信息; - `debug/dwarf`DWARF数据的读取、解析,数据压缩(.debug\_*)、不压缩(.zdebug_)两种格式均支持; > `debug`下几个与ELF无关的package说明: > > - macOS可执行程序、目标文件的格式并不是Unix/Linux比较通用的ELF格式,它使用的是macho格式,package `debug/macho` 是用来解析macho格式的; > - windows可执行程序、目标文件的格式有采用pe这种格式,`debug/pe`是用来解析pe格式; > - plan9obj这种格式比较特殊,它源于plan9分布式操作系统项目,`debug/plan9obj`用来解析这种plan9obj这种格式; > > 需要说明的是,在Linux下,go最终输出的文件格式虽然是ELF格式,但是在中途生成的目标文件*.o却不是采用的ELF格式,而是借鉴了plan9obj的格式,但是也有些变化。如果读者想查看go输出的目标文件格式,可以在这里找到其定义以及解析相关的package实现 "[cmd/internal/goobj/objfile.go](https://sourcegraph.com/github.com/golang/go@f5978a09589badb927d3aa96998fc785524cae02/-/blob/src/cmd/internal/goobj/objfile.go#L33)"。 > 但是由于该package为internal目录下,只允许在cmd/下的package引用,如果要正确读取go生成的\*.o目标文件格式的话,需要自己写工具。github上有类似项目可供借鉴,see [hitzhangjie/codemaster/debug](https://github.com/hitzhangjie/codemaster/tree/master/debug)。 接下来各个小节,我们将介绍下上述package的使用,了解它们对开发符号级调试器提供了哪些帮助。 参考内容: 1. How to Fool Analysis Tools, https://tuanlinh.gitbook.io/ctf/golang-function-name-obfuscation-how-to-fool-analysis-tools 2. Go 1.2 Runtime Symbol Information, Russ Cox, https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub 3. Some notes on the structure of Go Binaries, https://utcc.utoronto.ca/~cks/space/blog/programming/GoBinaryStructureNotes 4. Buiding a better Go Linker, Austin Clements, https://docs.google.com/document/d/1D13QhciikbdLtaI67U6Ble5d_1nsI4befEd6_k1z91U/view 5. Time for Some Function Recovery, https://www.mdeditor.tw/pl/2DRS/zh-hk ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/1-gopkgs-about-dwarf.md ================================================ ## Go DWARF Support ### 为什么要探讨这个问题 Go编译工具链在进行编译链接时会生成DWARF调试信息,有哪些Go库可以实现对这些数据的读取呢?读取的话,针对类型、变量、常量、函数(包括参数列表、返回值)等,有没有参考手册可以得知具体应该如何读取呢(不同语言不同程序构造的DWARF数据也不同)?现在2025年了,有没有这方面更友好的开源库、参考手册、文档呢? 我因为详细钻研过DWARF规范,所以我能非常自然理解DWARF数据生成、解析这其中的工作量有多大,我们有些读者可能会想难道和读写一个ELF文件的工作量有啥巨大的差距不成?是的,有非常大的差距,完全没法类比。大家看下delve调试器中pkg/dwarf下的代码量,就知道为什么我们要探讨Go DWARF Support这个问题了。 ```bash $ cloc path-to/delve/pkg/dwarf 35 text files. 34 unique files. 3 files ignored. github.com/AlDanial/cloc v 2.04 T=0.03 s (1200.7 files/s, 279205.9 lines/s) ------------------------------------------------------------------------------- Language files blank comment code ------------------------------------------------------------------------------- Go 34 920 573 6413 ------------------------------------------------------------------------------- SUM: 34 920 573 6413 ------------------------------------------------------------------------------- ``` 专注于调试领域的开发者,应该说非常小众,而Go目前仍然是一个比较年轻的语言,So: - Go团队不太可能在标准库里维护一个受众不多但是又“大而全”的DWARF实现上,非常耗费精力。 - Go编译工具链专注于生成DWARF调试信息,这部分信息是相对比较全面的,该生成的都生成了,缺点是文档比较少。 - debug/dwarf是专注于DWARF数据读取,但是也不是Go编译工具链生成的信息都支持读取,比如.debug_frame它就不支持读取。 也是因此,Go非官方调试器 [go-delve/delve](https://github.com/go-delve/delve) 内部才自己实现了这部分DWARF的生成、解析逻辑,它还自己实现了生成逻辑,一方面与Go编译工具链团队生成的DWARf数据做对比,一方面对Go编译工具链生成DWARF数据描述不够充分的时候,也方便从调试器开发者视角去反馈下此处应该如何生成更好,这样就形成了和Go核心团队的协作、共建。 > ps: 有个小哥学习调试器开发时,开发了个demo [ggg](https://github.com/ConradIrwin/ggg) ,当时也定制化了debug/dwarf,详见:[ConradIrwin/go-dwarf](https://github.com/ConradIrwin/go-dwarf)。这里只是举个定制化 `debug/dwarf`的例子,不代表这个库可用,而且最后更新已经是11年前的事情了。即使要用,也应该优先考虑 `go-delve/delve` 中的实现部分。 ### 实现1:Go标准库 debug/dwarf 前面小节我们介绍了go标准库 `debug/dwarf`,它提供了对DWARF调试信息的解析,作为官方实现它提供了底层的API,允许你遍历和检查DWARF数据结构。 相对来说,官方库是最基础和可靠的选择。如果需要进行更高级的分析或集成,可能需要基于官方库进行二次开发,在此基础上增加更多的特性、提供更高级的抽象等。我们分析了为什么当前 `debug/dwarf` 不太可能是达到“完美”程度的原因,也列举了 `go-delve/delve` 和 `ggg` 的例子。 OK,那我们看下 `debug/dwarf` 的支持程度和局限性,`debug/dwarf`,以go1.24为例: - 支持读取 .debug_ .zdebug_ sections; - 如果调试信息开了zlib或者zstd压缩,支持自动解压缩 `debug/elf.(*Section).Data()`; - 有些调试信息需要考虑进行重定位操作,支持按需重定位操作 `debug/elf.(*File).applyRelocations(a, b)`; - DWARFv4多个.debug_types,dwarf.Data里面对其section名进行额外的编号,方便定位问题; - 所有.debug_ .zdebug_ sections,dwarf.Data里面统一转换为.debug_ sections; - 所有的DWARF sections都会被正常读取! ```go func (f *File) DWARF() (*dwarf.Data, error) { // 获取 .[z]debug_ sections后面的后缀,其他section返回空:w dwarfSuffix := func(s *Section) string { ... } // 获取 .[z]debug_ sections的数据,并按需解压缩,按需重定位 sectionData := func(i int, s *Section) ([]byte, error) { ... } // DWARFv4 有非常多的.[z]debug_ sections,最开始 debug/dwarf 主要处理下面这些 sections var dat = map[string][]byte{"abbrev": nil, "info": nil, "str": nil, "line": nil, "ranges": nil} for i, s := range f.Sections { suffix := dwarfSuffix(s) if suffix == "" { continue } if _, ok := dat[suffix]; !ok { continue } b, _ := sectionData(i, s) dat[suffix] = b } // 创建dwarf.Data,只包含了已经处理的.[z]debug_ sections d, _ := dwarf.New(dat["abbrev"], nil, nil, dat["info"], dat["line"], nil, dat["ranges"], dat["str"]) // 继续处理 multiple .debug_types sections and other DWARFv4/v5 sections. for i, s := range f.Sections { suffix := dwarfSuffix(s) if suffix == "" { continue } if _, ok := dat[suffix]; ok { // Already handled. continue } b, _ := sectionData(i, s) // 如果有多个.debug_types sections,dwarf.Data里的section名加上编号,方便定位问题 if suffix == "types" { _ = d.AddTypes(fmt.Sprintf("types-%d", i), b); err != nil { } else { // 其他DWARF sections _ = d.AddSection(".debug_"+suffix, b); err != nil { } } return d, nil } ``` `debug/dwarf` 确实有读取所有的DWARF数据,但是这不够!读取、解析并提供了合适的API后,对我们才真正的有用。调试器要实现常规的调试能力,需要: - 支持类型、变量、常量的查看或者修改,需要读取解析.debug_info中的DIEs -- debug/dwarf支持 - 需要能实现指令地址与源代码位置之间的转换,需要读取解析.debug_line中的行号表 -- debug/dwarf支持 - 实现调用栈的回溯,需要知道pcsp的关系,需要读取解析.debug_frame中的调用栈信息表 -- debug/dwarf不支持!!! >ps: go runtime是利用了.gopclntab并结合tls.g信息生成调用栈。 - 其他的sections也没有提供对应的API来操作。 总的来说,就是 debug/dwarf 完成了DWARF数据的读取、解压缩、重定位,但是并没有提供全面完整的API覆盖,我们想读取不同类型的DWARF信息时就比较棘手。这也意味着,要实现调试器里面需要的各种DWARF数据的查询操作,我们要自己实现。 ### 实现2:Go工具链 cmd/internal/dwarf 在Go编译工具链层面,DWARF调试信息的生成,是分散在编译器、链接器中的,它们都涉及DWARF调试信息生成的工作,分工不同,cmd/internal/dwarf这个公共库编译器、链接器都在使用。 - `go tool compile` ,会记录一系列的 link.LSym (link.LSym.Type=SDWARFXXX,link.LSym.P=DWARF编码数据); - `go tool link`,会整合、转换、加工输入目标文件中编译器记录的上述信息,最终输出调试信息到.debug_ sections; 接下来两个小节,我们会详细介绍编译器、链接器的上述工作过程,对我们后续开发、测试自己的调试器还是很有价值的。 现在,我们先看下cmd/internal/dwarf这个package支持哪些功能: - dwarf_defs.go,定义了DWARF中的一些常量,DW_TAG类型、DW_CLS类型、DW_AT属性类型、DW_FORM编码形式、DW_OP操作指令、DW_ATE属性编码类型、DW_ACCESS访问修饰、DW_VIS可见性修饰、DW_VIRTUALITY虚函数修饰、DW_LANG语言类型(go是22)、DW_INL内联类型、DW_ORD按行(列)主序、DW_LNS行号表操作指令、DW_MACINFO宏定义操作、DW_CFA调用栈信息表操作,等等; 这些定义在 `go-delve/delve`中被归类到了不同的package中,这样更清晰一点。 - dwarf.go,定义了一些生成、编码DWARF调试信息的公共代码,DWARF调试信息的生成是由编译器和链接器完成的,dwarf.go中定义了一些生成DWARF调试信息的导出函数,编译器、链接器中均有使用这部分函数。 > ps: dwarf.go对我们帮助很大,非常具有参考价值,因为各种程序构造的DWARF表示,有不少是在这个文件中实现的。阅读这个源文件,能够帮助我们了解描述不同的程序构造使用的DIE TAG、Attr等DWARF描述元素,这样我们自己实现调试器时,需要从中提取必要信息时就知道如何精准的反向操作。 这部分代码主要是给Go编译工具链使用的,设计实现和编译工具链的其他部分紧密结合,很难拿出来复用。这个包的组织也是放在internal目录下,不像 `debug/dwarf` 是暴漏给普通Go开发者用的。即便觉得这部分代码非常有用,也要copy、paste后再做大量改动。`go-delve/delve` 中copy、paste了这部分代码用于生成DWARF数据进行比对、测试,但除了调试器本身这个项目,可能很难找到其他项目会这么干了。如果我们真想复用这部分代码可以服用 `go-delve/delve` 里的实现。 ### 实现3:go-delve/delve/pkg/dwarf #### how dlv handles DWARF? 以流行的go调试器 `go-delve/delve` 为例,它是如何处理DWARF调试信息的呢?有没有使用标准库呢?为了求证这几点,可以在git仓库下执行 `git log -S "DWARF()"`来搜索下提交记录,找到几条关键信息: 1. delve早期也是使用的标准库 `debug/dwarf`来实现调试信息解析,那个时候对go、delve都是一个相对早期的阶段,各方面都还不很成熟。 ```bash commit f1e5a70a4b58e9caa4b40a0493bfb286e99789b9 Author: Derek Parker Date: Sat Sep 13 12:28:46 2014 -0500 Update for Go 1.3.1 I decided to vendor all debug/dwarf and debug/elf files so that the project can be go get-table. All changes that I am waiting to land in Go 1.4 are now captured in /vendor/debug/*. ``` 2. delve开发者发现使用 `debug/dwarf`解析某些类型信息存在问题,于是使用package `x/debug/dwarf`予以了替换,临时先应付下这个问题。现在再看 `x/debug/dwarf`这个package,发现之前的一些源文件不见了,因为它已经被迁移到go源码树中。 ```bash commit 54f1c9b3d40f606f7574c971187e7331699f378e Author: aarzilli Date: Sun Jan 24 10:25:54 2016 +0100 proc: replace debug/dwarf with golang.org/x/debug/dwarf Typedefs that resolve to slices are not recorded in DWARF as typedefs but instead as structs in a way that there is no way to know they are really slices using debug/dwarf. Using golang.org/x/debug/dwarf instead this problem is solved and as a bonus some types are printed with a nicer names: (struct string → string, struct []int → []int, etc) Fixes #356 and #293 ``` 3. 后面 `debug/dwarf`修复了之前存在的问题,delve又从 `x/debug/dwarf`替换回了 `debug/dwarf`。 ```bash commit 1e3ff49610690e9890a669c95d903184baae1f4f Author: aarzilli Date: Mon May 29 15:20:01 2017 +0200 pkg/dwarf/godwarf: split out type parsing from x/debug/dwarf Splits out type parsing and go-specific Type hierarchy from x/debug/dwarf, replace x/debug/dwarf with debug/dwarf everywhere, remove x/debug/dwarf from vendoring. ``` 4. 后续delve自己实现对debug_line的解析,并与标准库对比了处理结果,发现处理的功能正确性上与标准库已经一致了。 不禁要问为什么要自己实现呢?我理解一方面是go、delve都在快速演进,go官方团队也没有在调试方面同步地下那么多功夫。另一方面,delve不可避免地要自己解析一部分调试信息。最终,delve开发者把.debug_line连同其他sections的解析全部重写,使得delve对调试信息的解析具备了更好的完备性。 ```bash commit 3f9875e272cbaae7e507537346757ac4db6d25fa Author: aarzilli Date: Mon Jul 30 11:18:41 2018 +0200 dwarf/line: fix some bugs with the state machine Adds a test that compares the output of our state machine with the output of the debug_line reader in the standard library and checks that they produce the same output for the debug_line section of grafana as compiled on macOS (which is the most interesting case since it uses cgo and therefore goes through dsymutil). ... ``` 总之标准库对调试信息的读取解析支持有限,go、delve都在快速演进中,很明显delve对DWARF的需求是明显比go本身强烈的。delve一开始使用标准库,后面发现有局限性,于是开始自己重写DWARF调试信息的读取解析。当然这个重写的过程中,也有借鉴go标准库中的实现,go编译工具链也有收到delve开发者的DWARF调试信息生成的优化建议,是一个协作、共建的过程。 我们了解到这个程度就可以了,只要Go标准库支持了,delve设计实现就会去向Go标准库靠拢,这个肯定是没问题的。但是Go标准库还没支持的,或者不打算支持的,那就得delve开发者先自己实现、验证,然后再反馈给Go编译工具链开发者,共建的形式来完善。这部分也是一个不断优化的过程,比如现在或者以后会继续向DWARF v5中的优秀特性看齐,这部分处理逻辑还会不断优化。 我们理解这个共建过程就可以了,我们自己实现调试器时,可以参考delve调试器当前的最佳实践来实现。 #### understand delve pkg/dwarf go-delve/delve里面dwarf操作相关的部分主要是在package `pkg/dwarf`中,简单罗列下主要实现了什么。 - pkg/dwarf/util: 该package下有些代码是从go标准库里面copy过来修改的,比如 `pkg/dwarf/util/buf.go`大部分是标准库代码,只做了一点微调,增加了几个工具函数来读取变长编码的数值、读取字符串和编译单元开头的DWARF信息。 - pkg/dwarf/dwarfbuilder: 该package提供了一些工具类、工具函数来快速对DWARF信息进行编码,比如向.debug_info中增加编译单元、增加函数、增加变量、增加类型。还有就是往.debug_loc中增加LocEntry信息。 go-delve/delve为什么要提供这样的package实现呢?我认为一方面go标准库没有提供这方面信息(工具链cmd/internal/dwarf虽有,前面讲了未纳入标准库、且难copy&paste后复用),对如何使用DWARF调试信息来完善地描述go程序构造等也没有那么高投入,go-delve/delve这里应该也是做了一部分这方面的探索,然后和go开发团队来协作共建的方式。所以这里维护这部分DWARF数据生成逻辑也就理解了。 - pkg/dwarf/frame: 这个package下提供了对.debug_frame、.zdebug_frame的解析,每个编译单元都有自己的.debug_frame,最后链接器将其合并成一个。对每个编译单元cu来说,都是先编码对应的CIE信息,然后再跟着编译单元cu中包含的FDE信息。然后再是下一个编译单元的CIE、FDEs……如此反复。对这部分信息,可以使用一个状态机来解析。 - pkg/dwarf/line: 这个package下提供了对.debug_line的解析,之所以自己实现,不用go标准库中的debug/gosym,前面已经提过很多次了,标准库实现只支持纯go代码,cgo代码不支持,缺失了这部分行表数据。之所以也不用标准库debug/dwarf,我认为也是delve的一种实现策略,相对来说,保证了delve实现DWARF解析、调试功能的完备性。 - pkg/dwarf/godwarf: 这里的代码,和go标准库debug/dwarf对比,有很多相似的地方,应该是在标准库基础上修改的。它主要是实现了DWARF信息的读取,并且支持ZLIB解压缩。以及为了支持DWARF v5中新增的.debug_addr增加的代码,.debug_addr有助于简化现有的重定位操作。还提供了对DWARF标准中规定的一些类型信息的读取。也支持.debug_info中DIE的读取解析,为了更方便使用,它将其组织成一棵DIE Tree的形式。 - pkg/dwarf/loclist: 同一个对象在其生命周期内,其位置有可能是会发生变化的,位置列表信息就是用来描述这种情况的。DWARF v2~v4都有这方面的描述,DWARF v5也有改进。 - pkg/dwarf/op: DWARF中定义了很多的操作指令,这个package主要是实现这些指令的操作。 - pkg/dwarf/reader: 在标准库dwarf.Reader上的进一步封装,以实现更加方便的DWARF信息访问。 - pkg/dwarf/util: 提供了一些DWARF数据解析需要用到的buffer实现,以及读取LEB128编解码、读取字符串表中字符串(以null结尾)的工具函数。 ## 本节小结 后面我们将参考go-delve/delve/pkg/dwarf中的实现,用于我们这个调试器的DWARF数据的解析。在使用之前我会带领大家过一遍这部分代码的设计实现,做到知其然知其所以然,这样咱们用的才放心,才能说真正的“掌握”了。这部分代码与DWARF调试信息标准息息相关,如读者能够结合接下来一章DWARF标准的内容(或者手边常备DWARF v4/v5调试信息标准)来阅读,经常性的写点测试代码,然后看看生成的DWARF调试信息长什么样子,这样理解起来会更加顺利、透彻。 > 我们开发调试器实际上只需要读,但为了让大家更好理解DWARF像derekparker、arzillia大佬们那样能自如地扩展对Go新特性的支持、能和Go工具链核心团队协作共建,我们也要思考如何用DWARF去描述不同的程序构造的问题,所以生成DWARF数据咱们也要适当掌握。尽管看上去“枯燥",枯燥是什么?我在整理这些看上去枯燥的文字的时候,从来没觉得枯燥。 接下来第8章中我们将介绍DWARF调试信息标准,第9章实现符号级调试器的功能,到时候我们“可能会”裁剪go-delve/delve,然后进行进一步的详细解释、示例代码演示。单单go-delve/delve/pkg/dwarf代码量就超过6500行,我们本就是出于学习交流的目的,为了节省本书篇幅、代码量,尽快让此书第1个完整版跟读者见面,我很可能会考虑裁剪delve代码的方式,比如保留大家都比较容易获得的linux+amd64环境下的实现,但是保留必要的抽象层次,这样大家还能了解到一个真正可用的调试器面临的更多挑战。 > 比如,删减其中与Linux ELF无关的一些代码,如某些与Windows PE、Darwin macho相关的代码,但是会保留对接不同平台、不同可执行程序文件格式的interface抽象。 这样一来可节省笔者时间,保证全书整体进度,不至于在过多的细节上耽搁太久,也能以更快的进度完成全书并开始勘误。沉淀知识使每位读者具备符号级调试器开发的能力,是我写作这本书始终不变的初衷。我们没有这样的必要性去0开始写一个DWARF读写库,希望读者们理解这么决策的原因。更何况这本电子书,已经经历过了太长的时间,它必须尽快出第1个完整版。也许在我们拥有更多贡献者以后,可以考虑提供一个更适合我们这个教程的比 `go-delve/delve/pkg/dwarf` 更精简的、恰到好处的实现。 ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/2-delve-into-internals.md ================================================ ## 如何跟踪DWARF生成过程 本章节开头我们介绍了 `go build` 执行期间实际调用的工具列表,DWARF调试信息生成逻辑是由compile、link生成的,本文介绍下编译器compile中生成DWARF调试信息的大致过程。 对于一个相对比较陌生的项目,大家可能会通过走读代码、调试器跟踪执行来大致了解其执行过程。 ### 走读代码的方式 这可能是我们首先想到的方式,走读代码可以掌握主流程以及边边角角的细节。但代码量比较大时,就需要注意排除一些无关代码,否则容易迷失在代码中。如果读者项目不熟,那就更让人头大了。 以go为例,编译工具链进考虑编译器、链接器,go源码行数高达44w。尽管作者对这部分代码已经比较熟悉了,但是如果不借助任何工具,走读代码还是会有“迷失在森林”中的感觉。 ```bash path-to/go/src/cmd $ cloc compile/ link/ 877 text files. 853 unique files. 34 files ignored. github.com/AlDanial/cloc v 2.01 T=1.00 s (853.0 files/s, 561112.0 lines/s) -------------------------------------------------------------------------------- Language files blank comment code -------------------------------------------------------------------------------- Go 810 28854 72117 442276 Snakemake 20 1968 0 13760 Markdown 4 379 23 1313 Text 6 60 0 146 Assembly 9 21 35 92 Objective-C 1 2 3 11 Bourne Shell 1 5 6 10 Bourne Again Shell 1 7 10 9 MATLAB 1 1 0 4 -------------------------------------------------------------------------------- SUM: 853 31297 72194 457621 -------------------------------------------------------------------------------- ``` 分享几个走读代码时我常用的vscode插件,对于应对这种中大型工程、流程长的处理逻辑时非常有帮助: - bookmarks:拉一个分支 notes/go1.24, 走读代码时即时添加书签,书签命名、描述遵循一定的格式,如:`"分类": "书签描述"`。这样后续查看起来就方便多了。 - codetour:拉一个分支 notes/go1.24,可以针对一个特定的流程,对关心的流程细节进行记录,首先创建一个tour,然后中途记录每个step添加描述,后续就可以一步步地回放流程中关键的步骤。 我们添加的书签、tours,都是存储在项目分支的 .vscode/ 目录下,记得提交入库,这样换台设备阅读代码时,可以无缝衔接,作者已经这样使用了多年,个人感觉还是非常有帮助的。 ### 调试跟踪的方式 调试器跟踪可以跳过代码中很多执行不到的分支逻辑,但是比较特殊的是,go编译工具链发行版本中是去掉了DWARF的,所以你如果想调试go编译工具链本身普遍会因为缺少DWARF调试信息而导致无法调试。 一个解决办法是,从源码重新构建编译工具链: ```bash # 下载go源码并切换到go1.24分支 git clone https://github.com/golang/go cd go git checkout v1.24 # 修改VERSION文件,在go1.24.0前面加上 'tests/'字样 # 此时go工具链构建流程就不会去掉DWARF生成的编译器、链接器选项了 cat >>VERSION<1000) need long time for attaching and detaching, continue? [Y/n] >>> press `y` to continue y add arg rule at 47cc40: {Type:1 Reg:0 Size:8 Length:1 Offsets:[0 0 0 0 0 0 0 0] Deference:[1 0 0 0 0 0 0 0]} add arg rule at 47cc40: {Type:1 Reg:0 Size:8 Length:1 Offsets:[8 0 0 0 0 0 0 0] Deference:[0 0 0 0 0 0 0 0]} add arg rule at 47cc40: {Type:1 Reg:0 Size:8 Length:1 Offsets:[16 0 0 0 0 0 0 0] Deference:[0 0 0 0 0 0 0 0]} INFO[0002] start tracing ... 🔬 You can inspect all nested function calls, when and where started or finished 23 17:11:00.0890 main.doSomething() { main.main+15 github/go-ftrace/examples/main.go:10 23 17:11:00.0890 main.add() { main.doSomething+37 github/go-ftrace/examples/main.go:15 23 17:11:00.0890 main.add1() { main.add+149 github/go-ftrace/examples/main.go:27 23 17:11:00.0890 main.add3() { main.add1+149 github/go-ftrace/examples/main.go:40 23 17:11:00.0890 000.0000 } main.add3+148 github/go-ftrace/examples/main.go:46 23 17:11:00.0890 000.0000 } main.add1+154 github/go-ftrace/examples/main.go:33 23 17:11:00.0890 000.0001 } main.add+154 github/go-ftrace/examples/main.go:27 23 17:11:00.0890 main.minus() { main.doSomething+52 github/go-ftrace/examples/main.go:16 23 17:11:00.0890 000.0000 } main.minus+3 github/go-ftrace/examples/main.go:51 🔍 Here, member fields of function receiver extracted, receiver is the 1st argument actually. 23 17:11:00.0891 main.(*Student).String(s.name=zhang>> press `Ctrl+C` to quit. INFO[0007] start detaching detaching 16/16 ``` 这个基于ebpf实现的跟踪工具,可以用来分析go源码执行历程,你不需要走读代码这么机械,也不需要使用调试器去控制执行,你只需要用go-ftrace去跟踪一遍程序执行,它就可以把执行期间走过的所有函数给输出出来。然后可以有的放矢的去看看源码,事半功倍! #### LLM 如虎添翼 哈哈哈,现在 LLM 也是一个非常好的办法,“hi,请给我解释下这段代码”。确实,我现在也经常使用这种方法,而且通常都有非常正向的帮助。 这些是我日常经常使用的一些AI产品和大模型: - Website: claude.ai / you.com / chatgpt.com / gemini.google.com / sourcegraph.com - App: 腾讯元宝 / 豆包 / kimi / gemini - LLM: claude / gpt-4o / qwen2.5 / gemma3 / deepseek / hunyuan - VSCode Extension: continue / copilot / cody ai / ... - Chrome Extension: Page Assist - Self-Hosted: Open-WebUI #### 其他方式 开发者的智慧,不是我能枚举的完的,我列举的是我个人职业生涯中一些经验,如果你有更好的了解程序执行流的方法,也可以分享一下。 ### 总结 有可能读者最初是想了解下调试器开发,但是读到这几个小节,因为我们用不少篇幅介绍了go编译工具链,大家可能也想去了解下go编译工具链、go运行时、go标准库的设计实现。作者当然理解一个喜欢钻研技术的同学有多么想穷尽所有细节,我理解,所以我分享了在我过去类似工作学习中认为还不错的掌握中大型工程“细节”的一些方法,如果你真的有这个必要。不同于业务代码中的一些相对简单的CRUD逻辑,不是看看文档、PPT、听别人口述个大概就可以说OK的,有些项目讲究的就是一个“精确”“严谨”,我非常欣赏那些愿意投入个人时间在这些枯燥的细节上稳扎稳打的技术人。你们在这些地方的投入,最终会不断丰满你们的羽翼,让你们飞的更高。 ps: 我说的更高,并不是世俗上认为的成功,而是一种“超越”。 ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/3-compiler-gen-dwarfdata.md ================================================ ## go tool compile: DWARF调试信息生成 ### gc.Main()→dwarfgen.RecordFlags() **记录当前构建信息到dwarf调试信息中去,方便调试器调试时查看tracee的构建细节** ```go 这个函数的主要目的是将编译器的命令行参数记录到 DWARF 调试信息中。DWARF 是一种调试信息格式,用于帮助调试器理解程序的内部结构。具体来说: 1. 函数接收一系列标志名称作为参数,这些标志是编译器命令行参数 2. 对于每个标志,函数会: - 检查标志是否存在 - 检查标志值是否与默认值不同(如果相同则跳过) - 根据标志类型(布尔型、计数型或普通型)以不同格式记录到缓冲区中 3. 特殊处理: - 对于布尔型标志(如 -race),如果值为 true,只记录标志名 - 对于计数型标志(如 -v),如果值为 1,只记录标志名 - 对于其他标志,记录标志名和值(如 -gcflags="-N -l") 4. 最后,这些参数会被存储在一个特殊的符号中: - 符号名格式为 dwarf.CUInfoPrefix + "producer." + base.Ctxt.Pkgpath - 符号类型设置为 objabi.SDWARFCUINFO(表示这是编译单元信息) - 允许重复(因为测试时可能会链接多个 main 包) - 将参数信息存储在符号的数据中 这样做的目的是让调试器能够知道程序是如何被编译的,这对于调试和问题诊断很有帮助。比如,如果程序是用 -race 编译的,调试器就能知道这是一个竞态检测版本的程序。 这个信息会被写入到最终的可执行文件中,作为 DWARF 调试信息的一部分。当使用调试器(如 GDB)时,这些信息可以帮助开发者更好地理解程序的编译环境和配置。 ``` ### gc.Main()→dwarf flags设置 **根据命令行参数设置对应的dwarf设置** ```go if base.Flag.Dwarf { base.Ctxt.DebugInfo = dwarfgen.Info base.Ctxt.GenAbstractFunc = dwarfgen.AbstractFunc base.Ctxt.DwFixups = obj.NewDwarfFixupTable(base.Ctxt) } else { // turn off inline generation if no dwarf at all base.Flag.GenDwarfInl = 0 base.Ctxt.Flag_locationlists = false } if base.Ctxt.Flag_locationlists && len(base.Ctxt.Arch.DWARFRegisters) == 0 { log.Fatalf("location lists requested but register mapping not available on %v", base.Ctxt.Arch.Name) } if base.Flag.Dwarf { dwarf.EnableLogging(base.Debug.DwarfInl != 0) } ``` ### gc.Main()→dwarfgen.RecordPackageName() **记录下当前编译单元的PackageName,记录在哪呢?生成一个符号表符号,类型为SDWARFCUINFO** ```go // RecordPackageName records the name of the package being // compiled, so that the linker can save it in the compile unit's DIE. func RecordPackageName() { s := base.Ctxt.Lookup(dwarf.CUInfoPrefix + "packagename." + base.Ctxt.Pkgpath) s.Type = objabi.SDWARFCUINFO // Sometimes (for example when building tests) we can link // together two package main archives. So allow dups. s.Set(obj.AttrDuplicateOK, true) base.Ctxt.Data = append(base.Ctxt.Data, s) s.P = []byte(types.LocalPkg.Name) } ``` ### gc.Main()→dumpGlobal(n)/dumpGlobalConst(n) **将当前localpackage中的全局变量、常量生成到dwarf调试信息** ```go for nextFunc, nextExtern := 0, 0; ; { ... if nextExtern < len(typecheck.Target.Externs) { switch n := typecheck.Target.Externs[nextExtern]; n.Op() { case ir.ONAME: dumpGlobal(n) case ir.OLITERAL: dumpGlobalConst(n) ... } nextExtern++ continue } ... } func dumpGlobal(n *ir.Name) { ... if n.Class == ir.PFUNC { return } if n.Sym().Pkg != types.LocalPkg { return } ... base.Ctxt.DwarfGlobal(types.TypeSymName(n.Type()), n.Linksym()) } // DwarfGlobal creates a link symbol containing a DWARF entry for // a global variable. func (ctxt *Link) DwarfGlobal(typename string, varSym *LSym) { myimportpath := ctxt.Pkgpath if myimportpath == "" || varSym.Local() { return } varname := varSym.Name dieSym := &LSym{ Type: objabi.SDWARFVAR, } varSym.NewVarInfo().dwarfInfoSym = dieSym ctxt.Data = append(ctxt.Data, dieSym) typeSym := ctxt.Lookup(dwarf.InfoPrefix + typename) dwarf.PutGlobal(dwCtxt{ctxt}, dieSym, typeSym, varSym, varname) } // PutGlobal writes a DIE for a global variable. func PutGlobal(ctxt Context, info, typ, gvar Sym, name string) { Uleb128put(ctxt, info, DW_ABRV_VARIABLE) putattr(ctxt, info, DW_ABRV_VARIABLE, DW_FORM_string, DW_CLS_STRING, int64(len(name)), name) putattr(ctxt, info, DW_ABRV_VARIABLE, DW_FORM_block1, DW_CLS_ADDRESS, 0, gvar) putattr(ctxt, info, DW_ABRV_VARIABLE, DW_FORM_ref_addr, DW_CLS_REFERENCE, 0, typ) putattr(ctxt, info, DW_ABRV_VARIABLE, DW_FORM_flag, DW_CLS_FLAG, 1, nil) } func dumpGlobalConst(n *ir.Name) { ... base.Ctxt.DwarfIntConst(n.Sym().Name, types.TypeSymName(t), ir.IntVal(t, v)) } // DwarfIntConst creates a link symbol for an integer constant with the // given name, type and value. func (ctxt *Link) DwarfIntConst(name, typename string, val int64) { myimportpath := ctxt.Pkgpath if myimportpath == "" { return } s := ctxt.LookupInit(dwarf.ConstInfoPrefix+myimportpath, func(s *LSym) { s.Type = objabi.SDWARFCONST ctxt.Data = append(ctxt.Data, s) }) dwarf.PutIntConst(dwCtxt{ctxt}, s, ctxt.Lookup(dwarf.InfoPrefix+typename), myimportpath+"."+name, val) } // PutIntConst writes a DIE for an integer constant func PutIntConst(ctxt Context, info, typ Sym, name string, val int64) { Uleb128put(ctxt, info, DW_ABRV_INT_CONSTANT) putattr(ctxt, info, DW_ABRV_INT_CONSTANT, DW_FORM_string, DW_CLS_STRING, int64(len(name)), name) putattr(ctxt, info, DW_ABRV_INT_CONSTANT, DW_FORM_ref_addr, DW_CLS_REFERENCE, 0, typ) putattr(ctxt, info, DW_ABRV_INT_CONSTANT, DW_FORM_sdata, DW_CLS_CONSTANT, val, nil) } ``` ### gc.Main()→enqueueFunc(f)+compilequeue ```bash gc.Main() \-> enqueueFunc \-> compileFunctions \-> compile \-> (*Progs).FLush \-> (*Progs).Flushplist \-> (*Link).populateDWARF ``` OK,详细展开看看: ```go func gc.Main(...) { ... for nextFunc, nextExtern := 0, 0; ; { if nextFunc < len(typecheck.Target.Funcs) { enqueueFunc(typecheck.Target.Funcs[nextFunc]) nextFunc++ continue } // The SSA backend supports using multiple goroutines, so keep it // as late as possible to maximize how much work we can batch and // process concurrently. if len(compilequeue) != 0 { compileFunctions(profile) continue } ... // Finalize DWARF inline routine DIEs, then explicitly turn off // further DWARF inlining generation to avoid problems with // generated method wrappers. // // Note: The DWARF fixup code for inlined calls currently doesn't // allow multiple invocations, so we intentionally run it just // once after everything else. Worst case, some generated // functions have slightly larger DWARF DIEs. if base.Ctxt.DwFixups != nil { base.Ctxt.DwFixups.Finalize(base.Ctxt.Pkgpath, base.Debug.DwarfInl != 0) base.Ctxt.DwFixups = nil base.Flag.GenDwarfInl = 0 continue // may have called reflectdata.TypeLinksym (#62156) } ... } } // 递归地分析fn中的body,如果内部还有新创建的fn将继续假如compilequeue func enqueueFunc(fn *ir.Func) { todo := []*ir.Func{fn} for len(todo) > 0 { next := todo[len(todo)-1] todo = todo[:len(todo)-1] prepareFunc(next) todo = append(todo, next.Closures...) } ... // Enqueue just fn itself. compileFunctions will handle // scheduling compilation of its closures after it's done. compilequeue = append(compilequeue, fn) } // compileFunctions compiles all functions in compilequeue. // It fans out nBackendWorkers to do the work // and waits for them to complete. func compileFunctions(profile *pgoir.Profile) { ... var compile func([]*ir.Func) compile = func(fns []*ir.Func) { for _, fn := range fns { fn := fn queue(func(worker int) { ssagen.Compile(fn, worker, profile) compile(fn.Closures) }) } } ... compile(compilequeue) } // Compile builds an SSA backend function, // uses it to generate a plist, // and flushes that plist to machine code. // worker indicates which of the backend workers is doing the processing. func Compile(fn *ir.Func, worker int, profile *pgoir.Profile) { f := buildssa(fn, worker, inline.IsPgoHotFunc(fn, profile) || inline.HasPgoHotInline(fn)) ... pp := objw.NewProgs(fn, worker) defer pp.Free() genssa(f, pp) ... pp.Flush() // assemble, fill in boilerplate, etc. ... } // Flush converts from pp to machine code. func (pp *Progs) Flush() { plist := &obj.Plist{Firstpc: pp.Text, Curfn: pp.CurFunc} obj.Flushplist(base.Ctxt, plist, pp.NewProg) } func Flushplist(ctxt *Link, plist *Plist, newprog ProgAlloc) { ... ctxt.populateDWARF(plist.Curfn, s) ... } // populateDWARF fills in the DWARF Debugging Information Entries for // TEXT symbol 's'. The various DWARF symbols must already have been // initialized in InitTextSym. func (ctxt *Link) populateDWARF(curfn Func, s *LSym) { // see more details below ... } ``` ### (*Link).populateDWARF(Func, *LSym) ```go // populateDWARF fills in the DWARF Debugging Information Entries for // TEXT symbol 's'. The various DWARF symbols must already have been // initialized in InitTextSym. func (ctxt *Link) populateDWARF(curfn Func, s *LSym) { ... info, loc, ranges, absfunc, lines := ctxt.dwarfSym(s) ... var scopes []dwarf.Scope var inlcalls dwarf.InlCalls if ctxt.DebugInfo != nil { scopes, inlcalls = ctxt.DebugInfo(s, info, curfn) } var err error dwctxt := dwCtxt{ctxt} startPos := ctxt.InnermostPos(textPos(s)) ... fnstate := &dwarf.FnState{ Name: s.Name, Info: info, Loc: loc, Ranges: ranges, Absfn: absfunc, StartPC: s, Size: s.Size, StartPos: startPos, External: !s.Static(), Scopes: scopes, InlCalls: inlcalls, UseBASEntries: ctxt.UseBASEntries, } if absfunc != nil { err = dwarf.PutAbstractFunc(dwctxt, fnstate) if err != nil { ctxt.Diag("emitting DWARF for %s failed: %v", s.Name, err) } err = dwarf.PutConcreteFunc(dwctxt, fnstate, s.Wrapper()) } else { err = dwarf.PutDefaultFunc(dwctxt, fnstate, s.Wrapper()) } if err != nil { ctxt.Diag("emitting DWARF for %s failed: %v", s.Name, err) } // Fill in the debug lines symbol. ctxt.generateDebugLinesSymbol(s, lines) } func PutAbstractFunc(ctxt Context, s *FnState) error {...} func putAbstractVar(...) func putAbstractVarAbbrev(...) func putattr(...) ... // 将函数声明记录到dwarf信息中 func PutConcreteFunc(ctxt Context, s *FnState, isWrapper bool) error {...} func putattr(...) func concreteVar(...) func inlinedVarTable(...) func putparamtypes(...) ... // 这些函数是将函数体中不同作用域的变量给记录到dwarf信息中 func putPrunedScopes(...) func putscope(...) func putparamtypes(...) ... func putInlinedFunc(...) ... func Uleb128put(...) ... // 将函数体中的语句的pc值变化、行号值变化记录到dwarf行号信息表中 func generateDebugLinesSymbol(...) func putpclcdelta(...) // pc< delta <-> ln delta ``` ### gc.Main()→ foreach func → (*DwarfFixupTable).Finalize() **貌似是有引用某些内联函数中定义的局部变量,此时可能需要这里处理下** ```go // Called after all functions have been compiled; the main job of this // function is to identify cases where there are outstanding fixups. // This scenario crops up when we have references to variables of an // inlined routine, but that routine is defined in some other package. // This helper walks through and locate these fixups, then invokes a // helper to create an abstract subprogram DIE for each one. func (ft *DwarfFixupTable) Finalize(myimportpath string, trace bool) { ... // Collect up the keys from the precursor map, then sort the // resulting list (don't want to rely on map ordering here). fns := make([]*LSym, len(ft.precursor)) idx := 0 for fn := range ft.precursor { fns[idx] = fn idx++ } sort.Sort(BySymName(fns)) ... // Generate any missing abstract functions. for _, s := range fns { absfn := ft.AbsFuncDwarfSym(s) slot, found := ft.symtab[absfn] if !found || !ft.svec[slot].defseen { ft.ctxt.GenAbstractFunc(s) } } // Apply fixups. for _, s := range fns { absfn := ft.AbsFuncDwarfSym(s) slot, found := ft.symtab[absfn] if !found { ft.ctxt.Diag("internal error: DwarfFixupTable.Finalize orphan abstract function for %v", s) } else { ft.processFixups(slot, s) } } } ``` ### DWARF数据最终记录在哪里了? OK, 先说结论,实际上是编译器将这些待生成的某个程序构造(类型定义、变量定义、常量定义、函数定义等)都用一个link.LSym来表示,将其符号类型设置为link.LSym.Type=SDWARFXXX类型,并且根据语言设计以及DWARF调试信息标准,根据多方约定好的生成方式(比如与链接器、调试器维护者沟通好),将该程序构造对应的DWARF编码数据写入到link.LSym.P中。 file: cmd/internal/obj/link.go ```go // An LSym is the sort of symbol that is written to an object file. // It represents Go symbols in a flat pkg+"."+name namespace. type LSym struct { Name string Type objabi.SymKind // <= 这里的类型为DWARF符号类型, // 1) 将来链接器会将其统一生成到.debug_ 相关的sections // 2) 那是不是所有DWARF信息都是通过LSym记录的呢? // 可以这么说!链接器负责整合、再加工这些信息,然后生成到.debug_ sections, // 比如典型的.debug_frames,编译器记录函数相关的LSym, // Attribute Size int64 Gotype *LSym P []byte // <= DWARF编码数据会记录在这里 R []Reloc Extra *interface{} // *FuncInfo, *VarInfo, *FileInfo, or *TypeInfo, if present Pkg string PkgIdx int32 SymIdx int32 } ``` file: cmd/internal/dwarf/dwarf.go ```go func (ctxt *Link) DwarfAbstractFunc(curfn Func, s *LSym) { ... if err := dwarf.PutAbstractFunc(dwctxt, &fnstate); err != nil { ctxt.Diag("emitting DWARF for %s failed: %v", s.Name, err) } } // Emit DWARF attributes and child DIEs for an 'abstract' subprogram. // The abstract subprogram DIE for a function contains its // location-independent attributes (name, type, etc). Other instances // of the function (any inlined copy of it, or the single out-of-line // 'concrete' instance) will contain a pointer back to this abstract // DIE (as a space-saving measure, so that name/type etc doesn't have // to be repeated for each inlined copy). func PutAbstractFunc(ctxt Context, s *FnState) error { if logDwarf { ctxt.Logf("PutAbstractFunc(%v)\n", s.Absfn) } abbrev := DW_ABRV_FUNCTION_ABSTRACT Uleb128put(ctxt, s.Absfn, int64(abbrev)) ... } // Uleb128put appends v to s using DWARF's unsigned LEB128 encoding. func Uleb128put(ctxt Context, s Sym, v int64) { b := sevenBitU(v) if b == nil { var encbuf [20]byte b = AppendUleb128(encbuf[:0], uint64(v)) } ctxt.AddBytes(s, b) } ``` file: cmd/internal/obj/dwarf.go ```go // A Context specifies how to add data to a Sym. type Context interface { PtrSize() int Size(s Sym) int64 AddInt(s Sym, size int, i int64) AddBytes(s Sym, b []byte) AddAddress(s Sym, t interface{}, ofs int64) AddCURelativeAddress(s Sym, t interface{}, ofs int64) AddSectionOffset(s Sym, size int, t interface{}, ofs int64) AddDWARFAddrSectionOffset(s Sym, t interface{}, ofs int64) CurrentOffset(s Sym) int64 RecordDclReference(from Sym, to Sym, dclIdx int, inlIndex int) RecordChildDieOffsets(s Sym, vars []*Var, offsets []int32) AddString(s Sym, v string) Logf(format string, args ...interface{}) } func (c dwCtxt) AddBytes(s dwarf.Sym, b []byte) { ls := s.(*LSym) ls.WriteBytes(c.Link, ls.Size, b) } ``` file: cmd/internal/obj/data.go ```go // WriteBytes writes a slice of bytes into s at offset off. func (s *LSym) WriteBytes(ctxt *Link, off int64, b []byte) int64 { s.prepwrite(ctxt, off, len(b)) copy(s.P[off:], b) return off + int64(len(b)) } ``` ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/4-linker-gen-dwarfdata.md ================================================ ## go tool link: 调试信息生成 ### ld.Main()->dwarfGenerateDebugSyms() 下面是链接器生成所有DWARF调试信息的路径, file: cmd/link/internal/ld/main.go ```go func Main() { ... // entry1: generate dwarf data .debug_info for all types, variables, ... dwarfGenerateDebugInfo(ctxt) ... // entry2: generate dwarf data for all other .debug_ sections dwarfGenerateDebugSyms(ctxt) ... // compress generated dwarf data dwarfcompress(ctxt) ... } ``` 分析一下这两个函数的关系: 1. 从代码注释中可以看到这两个函数是 DWARF 调试信息生成的两个主要入口点: ```go // dwarfGenerateDebugInfo generated debug info entries for all types, // variables and functions in the program. // Along with dwarfGenerateDebugSyms they are the two main entry points into // dwarf generation: dwarfGenerateDebugInfo does all the work that should be // done before symbol names are mangled while dwarfGenerateDebugSyms does // all the work that can only be done after addresses have been assigned to // text symbols. ``` 2. 它们的主要区别在于执行时机和职责: - `dwarfGenerateDebugInfo`: - 在符号名称被修改(mangled)之前执行 (源代码中的函数 `func Add(a, b int) int`,经过名称修饰后可能变成 `go.info.Add$main$int$int$int`) - 负责生成所有类型、变量和函数的调试信息条目 - 主要处理 DWARF 信息的内容生成 - `dwarfGenerateDebugSyms`: - 在文本符号(text symbols)的地址被分配之后执行 - 负责生成调试符号 - 主要处理 DWARF 信息的布局和最终输出 3. 从 `main.go` 中的调用顺序可以看出它们的执行顺序: ```go bench.Start("dwarfGenerateDebugInfo") dwarfGenerateDebugInfo(ctxt) // ... 中间有其他操作 ... bench.Start("dwarfGenerateDebugSyms") dwarfGenerateDebugSyms(ctxt) ``` 4. 具体实现上: - `dwarfGenerateDebugInfo` 主要做: - 初始化 DWARF 上下文 - 生成类型信息 - 处理编译单元 - 收集变量和函数信息 - `dwarfGenerateDebugSyms` 主要做: - 生成 .debug_line、.debug_frame 和 .debug_loc 等调试段 - 处理地址相关的信息 - 最终输出调试信息 5. 它们共同完成 DWARF 调试信息的生成,但分成了两个阶段: - 第一阶段(`dwarfGenerateDebugInfo`)专注于内容的生成 - 第二阶段(`dwarfGenerateDebugSyms`)专注于布局和输出 这种分阶段的设计使得 DWARF 调试信息的生成更加清晰和可控,同时也符合链接器的工作流程 - 先确定内容,再确定布局和地址。 ### entry1: dwarfGenerateDebugInfo ```go // dwarfGenerateDebugInfo generated debug info entries for all types, // variables and functions in the program. // Along with dwarfGenerateDebugSyms they are the two main entry points into // dwarf generation: dwarfGenerateDebugInfo does all the work that should be // done before symbol names are mangled while dwarfGenerateDebugSyms does // all the work that can only be done after addresses have been assigned to // text symbols. func dwarfGenerateDebugInfo(ctxt *Link) { ... d := &dwctxt{ linkctxt: ctxt, ldr: ctxt.loader, arch: ctxt.Arch, tmap: make(map[string]loader.Sym), tdmap: make(map[loader.Sym]loader.Sym), rtmap: make(map[loader.Sym]loader.Sym), } ... // traverse the []*sym.Library for _, lib := range ctxt.Library { consts := d.ldr.Lookup(dwarf.ConstInfoPrefix+lib.Pkg, 0) // traverse the []*sym.CompilationUnit for _, unit := range lib.Units { // We drop the constants into the first CU. if consts != 0 { unit.Consts = sym.LoaderSym(consts) d.importInfoSymbol(consts) consts = 0 } ctxt.compUnits = append(ctxt.compUnits, unit) ... newattr(unit.DWInfo, dwarf.DW_AT_comp_dir, dwarf.DW_CLS_STRING, int64(len(compDir)), compDir) ... newattr(unit.DWInfo, dwarf.DW_AT_go_package_name, dwarf.DW_CLS_STRING, int64(len(pkgname)), pkgname) ... // Scan all functions in this compilation unit, create // DIEs for all referenced types, find all referenced // abstract functions, visit range symbols. Note that // Textp has been dead-code-eliminated already. for _, s := range unit.Textp { d.dwarfVisitFunction(loader.Sym(s), unit) } } } // Make a pass through all data symbols, looking for those // corresponding to reachable, Go-generated, user-visible // global variables. For each global of this sort, locate // the corresponding compiler-generated DIE symbol and tack // it onto the list associated with the unit. // Also looks for dictionary symbols and generates DIE symbols for each // type they reference. for idx := loader.Sym(1); idx < loader.Sym(d.ldr.NDef()); idx++ { if !d.ldr.AttrReachable(idx) || d.ldr.AttrNotInSymbolTable(idx) || d.ldr.SymVersion(idx) >= sym.SymVerStatic { continue } t := d.ldr.SymType(idx) switch t { case sym.SRODATA, sym.SDATA, sym.SNOPTRDATA, sym.STYPE, sym.SBSS, sym.SNOPTRBSS, sym.STLSBSS: // ok default: continue } // Skip things with no type, unless it's a dictionary gt := d.ldr.SymGoType(idx) if gt == 0 { if t == sym.SRODATA { if d.ldr.IsDict(idx) { // This is a dictionary, make sure that all types referenced by this dictionary are reachable relocs := d.ldr.Relocs(idx) for i := 0; i < relocs.Count(); i++ { reloc := relocs.At(i) if reloc.Type() == objabi.R_USEIFACE { d.defgotype(reloc.Sym()) } } } } continue } ... // Find compiler-generated DWARF info sym for global in question, // and tack it onto the appropriate unit. Note that there are // circumstances under which we can't find the compiler-generated // symbol-- this typically happens as a result of compiler options // (e.g. compile package X with "-dwarf=0"). varDIE := d.ldr.GetVarDwarfAuxSym(idx) if varDIE != 0 { unit := d.ldr.SymUnit(idx) d.defgotype(gt) unit.VarDIEs = append(unit.VarDIEs, sym.LoaderSym(varDIE)) } } d.synthesizestringtypes(ctxt, dwtypes.Child) d.synthesizeslicetypes(ctxt, dwtypes.Child) d.synthesizemaptypes(ctxt, dwtypes.Child) d.synthesizechantypes(ctxt, dwtypes.Child) } ``` ### entry2: dwarfGenerateDebugSyms ```go // dwarfGenerateDebugSyms constructs debug_line, debug_frame, and // debug_loc. It also writes out the debug_info section using symbols // generated in dwarfGenerateDebugInfo2. func dwarfGenerateDebugSyms(ctxt *Link) { if !dwarfEnabled(ctxt) { return } d := &dwctxt{ linkctxt: ctxt, ldr: ctxt.loader, arch: ctxt.Arch, dwmu: new(sync.Mutex), } d.dwarfGenerateDebugSyms() } ``` ### ld.Main()→dwarfcompress(*Link) **linker对dwarf调试信息进行必要的压缩** ```go // dwarfcompress compresses the DWARF sections. Relocations are applied // on the fly. After this, dwarfp will contain a different (new) set of // symbols, and sections may have been replaced. func dwarfcompress(ctxt *Link) { ... } ``` ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/5-headto-dwarf.md ================================================ ## 可以了解DWARF了 回头看下,我们的底子已经比较扎实: - 1~2章,介绍了作者分享本书内容的初衷; - 第3章,介绍了调试领域的专业术语; - 第4章,一起学习了调试相关的基础知识,目的、依赖的支持、安全为目的的反调试措施; - 第5章,一起分析了调试器的功能性需求、非功能性需求,明确了我们要做什么; - 第6章,一起实现了功能相对完整的指令级调试器,比较详细地介绍了调试的底层控制机制; - 第7章,这是我们为符号级调试器开发打基础的章节,倾注大量精力从ELF文件结构组织,再到符号、调试符号以及编译工具链如何生成它们,后续如何利用它们。 读者朋友能够坚持到现在,非常棒 👍,此处应该为自己鼓鼓掌 。不敢说读到这里已经超越了别人,但肯定超越了自己。 第8章我们将正式走进DWARF,一起来学习DWARF调试信息标准,掌握其描述代码和数据的方式,一窥DWARF标准对不同高级语言的高屋建瓴的抽象建模能力。届时你会发现DWARF标准除了复杂,也有高超、强大、美的一面。 ================================================ FILE: book/7-headto-sym-debugger/7-headto-dwarf/README.md ================================================ ## 挺近DWARF 前面介绍go标准库debug/dwarf时,我们展示了几个读取DWARF中源文件、函数名、函数参数信息的示例,但是我们没有讨论 : 1. 这些DWARF调试信息具体是如何生成的? 我们知道是编译器、链接器生成的,但是具体的生成过程是怎样的,编译器和链接器在这个过程中分别做了哪些工作? 2. 生成DWARF调试信息时具体是利用了DWARF的哪些描述能力? 针对不同的类型、变量、常量、函数(包括参数、返回值及局部变量)、调用栈、行号表,Go工具链如何用DWARF来描述的?要先理解这部分才能反过来实现正确的读取。 包括后续调试器实现时,我们可能要经常写一些测试用的小程序片段,检查Go编译工具链生成的DWARF调试信息,理解它然后再尝试去读取它、将它和源码关联起来。 3. go标准库对DWARF数据的读写足够完备吗? 在前面准备DWARF数据读取的示例时,我们发现并不是DWARF v4里面所有的关键描述信息(.debug_ sections)都有被读取,比如调用栈.debug_frame就没有被读取。 4. 除了go标准库以外,还有没有其他维护更好的Go DWARF开源库? 比如go-delve/delve这个调试器,几乎是随着go语言发展而同步发展的,这期间go团队对DWARF的支持有限,那delve开发者是解决DWARF数据读写问题的。 5. 实现一个基于DWARF的符号级调试器,有哪些库可以使用帮我们解决DWARF数据的读写问题? 不同语言的不同程序构造,其DWARF描述也不同,如果从0开始实现DWARF数据读写逻辑,工作量很大。我们要对比下go标准库、delve等项目中的实现来选择合适的库。 这些是咱们理解了调试原理之后,进一步去工程化时必须要思考的一个问题。本节我们就来继续钻研下。 ================================================ FILE: book/7-headto-sym-debugger/README.md ================================================ ## 符号级调试基础 ### 内容回顾 前面我们介绍了指令级调试过程中对tracee的各种控制,线程跟踪、执行到断点、单步执行、读写内存、读写寄存器等等,这些也是符号级调试必备的控制能力。前面提过一个设计良好的符号级调试器至少要做到3层架构,包括UI层、符号层、目标层,这样软件结构组织上清晰、扩展性也更好: 、![debugger-arch-1](../5-debugger-skeleton/assets/debugger-arch-1.png) 一起来回顾下调试器的三层架构: - UI层 (UI layer),主要负责与用户交互,1)执行调试动作,如添加断点、单步执行等;2)展示调试信息,如变量值、堆栈信息等。分离出UI层便于将交互和展示逻辑与核心调试逻辑分离开,便于更改或支持不同的用户界面。 - 符号层 (Symbolic Layer),主要负责解析调试符号信息,如理解变量、函数、源码位置与内存指令和数据地址的转换、调用栈等,它是连接用户UI操作与对目标程序执行控制的桥梁,比如我们打印一个变量值时通过变量名来打印,添加断点时断点位置使用源码file:lineno来添加。分离出符号层,可以更容易地支持不同的调试信息格式。 - 目标层 (Target Layer),目标层直接与被调试程序交互,负责执行调试命令对进程进行控制、数据读写,如设置断点、单步执行、读取内存和寄存器数据等。分离出目标层,可以更方便地支持不同的平台,如支持不同的操作系统、硬件架构。第六章实现指令级调试时对进程的控制能力,会下沉一部分到目标层。 ### 面临挑战 符号级调试,依赖于调试信息标准指导下编译器、链接器生成的调试信息。调试信息目前有多种,其中DWARF(Debugging With Attributed Record Formats)现在被广泛使用。go语言编译工具链也是采用的DWARF,调试器gdb、delve也支持DWARF。 我们准备好认识DWARF了吗?恐怕还没有。在详细介绍DWARF对不同编程语言的强大描述能力之前,我需要先假设读者对编译工具链的认识还没有那么充分(事实可能果真如此),这里做最坏的打算、补充一些必要的知识,以让大部分读者朋友可以在此基础上顺利过渡到DWARF章节,然后我们再一起轻装上阵进入符号级调试器的开发部分。如果读者对这方面很熟,也可以适当加快浏览速度。 OK,那我们迅速总结下,实现符号级调试器,除了指令级调试部分我们已掌握的内容,接下来还需要攻克的就是了解清楚go编译器、链接器如何借助DWARF来描述go语言的不同程序构造,这样调试器读取了go程序中的DWARF调试信息后,也可以知道描述的具体是go语言里的什么程序构造。 以Linux文件格式ELF文件为例,编译器、链接器负责生成DWARF调试信息,并将其存储在ELF文件的 `.(z)debug_` sections中。从DWARF标准来看,根据描述对象的不同,DWARF调试信息可以细分为下面这些大类: - 描述数据类型; - 描述变量; - 描述函数定义; - 描述行号表; - 描述调用栈信息表; - 描述符号表; - 描述字符串表; - 等等。 编译工具链除了生成DWARF调试信息,也会考虑语言运行时本身的一些特性支持,这会添加一些语言独有的sections。还需要要考虑生成来兼容现有二进制工具的一些常见的sections。比如go语言编译器、链接器会生成DWARF调试信息(.[z]debug_* sections)供调试器使用,它还额外生成.gosymtab、.gopclntab用于go runtime来跟踪调用栈信息,生成.note.go.buildid来保留go buildid信息。另外,也会生成.symtab供readelf等通用的二进制分析工具使用。 符号级调试的实现,要依赖DWARF,但是不是完全依赖DWARF还是要看具体实现。这要看编译器、链接器有没有生成足够完备的调试信息,或者调试信息解析效率是否足够高。有些语言的编译工具链没有做到这个程度,或者使用的DWARF版本对数据格式设计解析起来没那么高效,有些调试器就会退而求其次,去读取一些其他的ELF sections来帮助实现调试功能,或者帮助改善调试效率、改善调试体验。 所以说,实现符号级调试器,理论上来说可以借助DWARF来实现,但是工程上要考虑更多现实问题。实现一个高效可用的符号级调试器,需要认识到这个地方在以前可能是个挑战。现在应该不用担心了,go-delve/delve就是完全借助DWARF,而gdb还是用了部分符号表中的信息。 ### 本章目标 本章节我准备介绍下Linux下可执行程序常用的ELF文件格式、sections和segments的区别,以及编译器、链接器、加载器是如何协同工作的。我们写的程序,是如何从源码到可执行程序,到被加载到内存地址空间,被操作系统进程调度器调度执行。然后,我们会简单介绍下go语言相关的一些有趣的特性实现,比如协程。这个过程中,我们会介绍编译器、链接器为什么要生成某些sections、segments,以及segments如何被loader加载到进程地址空间,如何完成符号解析、重定位。在本章之后,读者会对编译工具链、ELF文件中的各个部分有个更清晰的认识,会充分认识到这是一个经过了精妙设计的协作生态。 如果我们选择跳过这一章的话,可能存在如下问题:1)读者可能不熟悉ELF、编译器、链接器的工作原理,短时间内也难以理顺,很可能在这里碰壁后失去继续下去的信心。2)调试器设计实现也确实离不开这部分知识,还不如系统性地把这个浑水给趟完,免得读者还要自己去搜索各种资料来补齐。3)我们会经常提到一些术语,比如符号在多个场景下有使用但是实则是不同的东西,读者不了解本章内容很可能会搞混很多技术细节。 所以,这一章最后还是和大家见面了,大家读完后能有个更全面的认识。本章先介绍一些ELF基础知识,包括一些重要的sections、segments是干什么用的,然后介绍下编译器、链接器的工作过程,它们是怎么借助ELF中的某些sections数据的,以及它们将DWARF调试信息生成到什么为止,大致如何进行查看。然后第8章我们可以介绍下DWARF调试信息是如何描述程序的,我们将在第9章进入符号级调试开发。大家也可以带着一个目标阅读本章,最起码要知道DWARF调试信息是何人何时何地生成的、存储在哪里、由谁读取并利用、如何读取。 > ps:关于“符号”这个术语,其在不同阶段扮演着不同的角色,携带的信息也各有侧重: > > - **.symtab 中的符号信息** 主要供链接器在链接时进行符号解析和重定位,或者在程序加载时由动态链接器(loader)使用。这些信息通常包含函数地址、全局变量地址等,用于将不同的代码段和数据段组合成可执行文件。 > - **.debug_info (或 .debug_*) 中的信息** 主要面向调试器,用于提供源码级别的调试信息,例如类型名、变量名、函数名等,这些信息以 DWARF (Debugging With Attributed Record Formats) 的 DIE (Debugging Information Entry) 形式存储。调试器利用这些信息进行符号显示、断点设置、单步调试等操作。 > - **编译器在词法分析、语法分析和语义分析阶段** 会对源码中的类型名、变量名、函数名等符号进行分析,并生成包含更详细信息的内部符号表(不是.symtab)。这些信息不仅用于类型安全检查等分析过程,也为后续的优化和代码生成提供依据。 > > 注意不同情景下提到“符号”这个术语,读者不要混淆相关的含义和技术细节。 ================================================ FILE: book/8-dwarf/1-history.md ================================================ ## 发展历史 DWARF调试信息标准,主要是面向开发者的,用以指导如何生成、如何消费调试信息,具体就是编译器、链接器开发者需要参考DWARF标准来生成调试信息,而调试器开发者需要参考DWARF来消费调试信息。我们先不妨了解下DWARF调试信息标准的发展史。 ### DWARF v0 (1988) DWARF调试信息标准的发展,离不开DWARF调试信息标准委员会的大力支持。DWARF调试信息标准委员会最初成立于1988年,本来是Unix International,Inc.的编程语言特殊兴趣小组(PLSIG),其初衷是为了促进Unix System V Release 4(SVR4)的发展。1988年,Bell实验室设计了DWARF调试信息格式用于SVR4。后面PLSIG起草了DWARF标准的v1版本,DWARF调试信息格式从此开始了真正的标准化之路。 ### DWARF v1 (1992/10) PLSIG起草了DWARF标准的v1版本,该标准与AT&T的SVR4编译器和调试器当时使用的DWARF调试格式兼容。1992年10月,DWARF v1.1.0发布,作为一个新生儿,该版本问题较多,难以被认可、接受。 ### DWARF v2 (1993/07) 1993年7月,DWARF v2.0.0发布。 DWARF v1版本中,生成的调试信息占存储空间很大,DWARF v2版本中增加了多种编码格式对数据进行压缩。DWARF v2 和v1相比,有一定改进,但因为DWARF v2与v1不兼容,业内人士认为其问题较多,还算不上成熟。 DWARF v2依然没有立即获得广泛的接纳,一方面因为DWARF仍是个新生儿,另一方面与Unix International宣布解散有关。委员会没有收到或处理任何行业评论,也没有发布最终标准。后来,委员会邮件列表由OpenGroup(以前称为XOpen)托管。 那时候Sun公司决定采用ELF作为Solaris平台上的文件格式,DWARF本来是为ELF设计的调试信息格式,但Sun并没有将DWARF作为首选调试信息格式,而是继续使用Stabs(stabs in elf)。那时候Linux也是一样的做法,这种情况一直持续到20世纪90年代才发生改变。 ### DWARF v3 (2005/12) DWARF委员会于1999年10月进行了重组,并在接下来的几年中解决DWARF v2中存在的问题,并添加一些新功能。 在2003年中,该委员会成为Free Standards Group (自由标准组,FSG)的工作组,该组织是为促进开放标准而成立的行业联盟。 经过行业审查和评论后,DWARF v3于2005年12月发布。 该版本增加了对Java、C++ namespace、Fortran 90等的支持,也增加了一些针对编译器、链接器的优化技术。如使用 Common Information Entry (简称CIE)中字段 return_address_register 存储调用栈的返回地址,该字段使用无符号LEB编码算法进行编码,有效压缩小整数占用的存储空间。 ### DWARF v4 (2010/06) DWARF委员会于2007年2月从FSG(Free Standards Group)退出,当时FSG与Open Source Development Labs合并组建了Linux Foundation,该基金会更侧重于推广Linux。 自那时以来,DWARF委员会一直处于独立的状态。 DWARF委员会的意见是,从DWARF v2或v3迁移到更高版本应该是简单易行的。 在DWARF v4中,几乎所有DWARF v2和v3的关键设计都保持不变。 2010年,DWARF委员会发布了DWARF v4,该版本的焦点围绕在改善数据压缩、更好地描述编译器优化后代码、增加对C++新特性的描述支持等。 ### DWARF v5 (2017/02) 对具有源语言调试和调试格式经验、对提升或扩展DWARF调试格式感兴趣的编译器和调试器开发人员,DWARF调试信息格式委员始对这些人员终保持开放态度。 2017年,DWARF v5发布,该版本在很多方面都做了改善、提升,包括更好的数据压缩、调试信息与可执行程序的分离、对macro和源文件的更好的描述、更快速的符号搜索、对编译器优化后代码的更好描述,以及其他功能、性能上的提升。 DWARF也是现在go语言工具链使用的调试信息格式,截止到go1.12.10,当前采用的版本是DWARF v4。在C++中,某些编译器如gcc已经开始应用了部分DWARF v5的特性,go语言也有这方面的讨论,如果对此感兴趣,可以关注go语言issue:: https://github.com/golang/go/issues/26379. ### DWARF v6 working draft (2023/12) 目前DWARF v4应用应该是最多的,有些语言在慢慢向DWARF v5看齐,尽管如此,DWARF v6标准已经在路上了。由于当前还处于草案阶段,我们先不予以介绍。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf ================================================ FILE: book/8-dwarf/2-overview.md ================================================ ## DWARF内容概览 ### 内容概览 大多数现代编程语言都采用块结构:每个实体(例如,类定义或函数)都包含在另一个实体中。一个 C 程序中的每个文件可能包含多个数据定义、多个变量定义和多个函数。在每个 C 函数中,可能有几个数据定义,再后面跟着可执行的语句列表。一个语句可能是一个复合语句,复合语句又可以包含数据定义和更简单的可执行语句。这创建了词法作用域,名称仅在定义它的作用域内可见。要在一个程序中找到特定符号的定义,首先在当前作用域中查找,然后在连续的封闭作用域中查找,直到找到该符号。在不同的作用域中,同一个名称可能有多个定义。编译器自然地将程序在内部表示为一棵树。 DWARF 遵循这种模型,它的调试信息条目(DIE) 本身也是块结构的。每个描述条目都包含在一个父级的描述条目中,并且可以包含子描述条目。一个节点也可能会包含1个或者多个兄弟描述条目。所以说,程序的 DWARF DIE数据也是一个树状结构,类似于编译器工作期间构建的语法树,其中每个节点都可以有子节点或兄弟节点。这些节点可以代表类型、变量或函数。 DWARF DIE可以以统一的方式进行扩展(比如扩展DIE的Tags、Attributes),以便调试器可以识别并忽略扩展,即使它可能不理解其含义。但这比大多数其他调试格式遇到不认识的数据时直接报致命错误要好多了。DWARF 的设计宗旨也是为了通过扩展来支持更多编程语言、更多特性,并且不受限于特定的架构、大小端限制。 除了上述DIE数据(.debug_info)以外,DWARF数据中还有一类数据也很重要,如行号表(.debug_line)、调用栈信息表 (.debug_frame)、宏信息 (.debug_macro)、加速访问表信息 (.debug_pubnames, .debug_pubtype,.debug_pubranges)等等。由于篇幅原因,难以在一个章节里面覆盖DWARF调试信息标准的所有细节,要知道单单DWARF v4内容就有325 pages。要更加深入细致地了解这部分内容,就需要阅读DWARF调试信息标准了。 虽然 DWARF 最初是设计出来用于 ELF 文件格式,但它在设计上支持扩展到其他文件格式。总的来说,现在DWARF是最广泛使用的调试信息格式,这得益于其标准化、完整性和持续演进。它不仅被主流编程语言采用,还在不断改进以适应新的需求。虽然存在其他调试信息格式,但DWARF凭借其优势成为了事实上的标准。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf ================================================ FILE: book/8-dwarf/3-dwarfdata.md ================================================ ## DWARF数据分类 DWARF (Debugging With Attributed Record Formats) 使用一系列数据结构来存储调试信息,这些信息允许调试器提供源代码级别的调试体验。核心概念是 **调试信息条目 (DIE, Debugging Information Entry)**,以及支持这些条目的关键表结构。 ### DWARF DIEs #### Tags & Attributes DWARF 使用 **调试信息条目 (DIE, Debugging Information Entry)** 来表示程序中的各种构造,例如变量、常量、类型、函数、编译单元等。每个 DIE 包含以下关键元素: - **Tag:** 一个标识符(例如 `DW_TAG_variable`,`DW_TAG_pointer_type`,`DW_TAG_subprogram`),指示DIE代表的程序构造的类型。 这些tag定义了DIE的语义。 - **Attributes:** 键值对,提供关于DIE的额外信息。例如,一个变量的DIE可能会有 `name`(变量名), `type` (变量类型), `location` (变量在内存中的位置) 等属性。 #### DIEs之间的关系 - **Children:** DIE可以包含其他DIE作为其子节点。这些子节点构成了树形的层级结构,用于描述复杂的程序构造。 例如,一个编译单元中包含了定义的函数,而每一个函数又包含了函数参数、返回值以及其局部变量。Children DIEs在存储上紧跟在parent DIE之后,读取Children DIEs直到遇到一个null DIE对象表示结束。 - **Siblings**: DIE之间的引用还可以通过属性实现。例如,一个描述变量的DIE需要有属性指明其数据类型,即属性 `DW_AT_type`,它指向1个描述数据类型的DIE。这种层级关系允许DWARF描述复杂的类型和作用域结构。 DIEs之间建立了Children、Siblings这两个不同维度上的引用关系,实际上形成了一个巨大的树,为了减少存储时的存储占用,也设计了一些编码方式来应对。 #### DIEs的分类 根据DIEs描述数据类型的不同,大致可以分为:描述数据和数据类型的,描述函数和可执行代码的。 - 描述数据和数据类型:比如描述基本类型、组合类型,比如描述array、struct、class、union 和 interface 类型,比如描述 variable,比如描述变量所在的位置信息的位置表达式; - 描述函数和可执行代码:比如描述函数 subprogram,比如描述编译单元 compilation unit; ### 重要表结构数据 为了支持源代码级别的调试,符号级调试器需要两张重要的表:行号表 (Line Number Table) 和调用栈信息表 (Call Frame Information)。 1. **行号表 (Line Number Table):** 建立了程序代码指令地址和源文件位置(file:line:col)之间的映射关系,它通常包含源文件名称、行号、列号、以及对应的指令地址。通过这里的映射表,允许调试器调试期间将当前执行到的位置(PC)转换为源代码中的位置进行显示;调试器参照此表可以将源码位置转换为内存指令地址,并在指令地址处添加断点,使我们可以用源文件位置添加断点。| 行号表中记录了如下细节信息,使我们可以做更多事情: - 对一个函数,指示函数序言 (prologue) 和函数结尾 (epilogue) 的指令,可以据此绘制函数的callgraph。 - 对一行源码,可能包含一个或多个表达式、语句,对应多条指令,它能指示第一条指令的位置,以在准确位置添加断点。 2. **调用栈信息表 (Call Frame Information):** 允许调试器根据指令地址确定其在调用栈上的栈帧。这对于跟踪函数调用和理解程序的执行流程至关重要。它记录了执行时指令地址PC,与当前的 "栈指针SP" 和 "帧指针FP" 的值,以及返回地址。 为了减小上述表的存储占用,DWARF 使用状态机和字节码指令来编码这些表。这些指令指示状态机如何处理行号信息和栈帧信息,从而避免了冗余数据的存储。调试器加载这些编码后的数据,并将其交给状态机执行,状态机的输出结果就是调试器所需要的表。这种编码方式显著减少了调试信息的大小,使得DWARF能够在各种平台上使用。 ### 其他DWARF数据 除此之外,DWARF中还有些其他数据,比如加速查询用的数据(Accelerated Access)、宏信息(Macro Information)等。 ### 本节小结 本文简单介绍了DWARF调试信息中我们打交道最多的几类数据,DIE是对不同程序构造的描述,而行号表、调用栈信息表则是对程序执行时静态视图、动态视图的一种体现,还有些其他用途的DWARF数据。OK,接下来,将先介绍如何使用DIE对不同程序构造进行描述。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf ================================================ FILE: book/8-dwarf/4-die/1-desc-data-type.md ================================================ ## 描述数据和类型 软件调试期间,我们经常打印变量值、查看变量类型、修改变量值,如dlv的print、whatis、set操作,这些操作的实现就离不开DWARF对数据和数据类型的描述。 不同的编程语言都定义了内置的数据类型,也提供了自定义数据类型的方法。在不同编程语言中,即使是看上去名字一样的基本数据类型,在相同硬件、OS上可能也是不同的,更不用说不同硬件、OS上的情况下了,如int在C和Go中的不同,Go int在32位、64位下的不同。 如果DWARF要对不同编程语言实现精准的低级表示、描述,应该怎么做?DWARF首先根据机器硬件抽象出几种基本类型(数值类型),在此基础上可以通过基本数据类型的组合来构建其他复合数据类型,这些新数据类型也可以进一步用于构建其他自定义的数据类型。 下面我们来看看如何使用DIE来描述数据和数据类型。 ### 基本类型 每种编程语言都定义了一些基本数据类型,并内置到语言的类型系统中。例如,C、Go和Java 都定义了 int,Java提供了明确的定义,int在任何平台上都是4字节,但 C、Go 只指定了一些一般特征,允许编译器选择最适合目标平台的实际规格,如Go在32位和64位操作系统上分别是4字节和8字节。有些语言更特殊,如 Pascal 甚至允许定义新的基本类型,例如可以容纳 0 到 100 之间的整数值的整数类型。 ```go // see: src/cmd/compile/internal/types2/sizes.go var gcArchSizes = map[string]*gcSizes{ "386": {4, 4}, // 32-bit "amd64": {8, 8}, // 64-bit "amd64p32": {4, 8}, // 32-bit pointers on 64-bit CPU // ... } type gcSizes struct { WordSize int64 // word size in bytes - must be >= 4 (32bits) MaxAlign int64 // maximum alignment in bytes - must be >= 1 } ``` 简言之,就是不同编程语言、平台存在这样的事实: - 相同语言在不同硬件平台上,数据类型相同的情况下,其尺寸可能也不同; - 不同语言在相同的硬件平台上,数据类型相同的情况下,其尺寸也可能不同。 在 DWARF v1及其他调试信息格式中,编译器和调试器应该就 int 究竟多少字节达成一种一致,类似硬编码的方式。但是当同一硬件可以支持不同大小的整数,或者当不同的编译器对同一目标处理器做出不同的实现决策时,这会变得有些尴尬。这些通常没有记录在调试信息中的假设,将使得不同编译器或调试器之间,甚至同一工具的不同版本之间难以实现兼容性。 如何将这些编程语言中的基本类型也能够灵活地映射为不同软硬件平台上的bytesize?C语言还支持位字段,即使存储分配上分配了N字节,但是实际上有可能仅使用一部分bits,这种又如何描述? DWARF v2解决了此问题,它提供了一种低级映射方案,可以实现“**简单数据类型**”和“**目标软硬件平台上的实现**”之间的灵活映射。 **DW_TAG_base_type**,对应的attributes包括(可以参考DWARFv2-Appendix: Current Attributes by Tag Value): | Attributes | Description | | :--------------- | :-------------------------------------------------------------------------------------------------------------------- | | DW_AT_name | 类型名称,如 int | | DW_AT_encoding | 指示应该如何编码、解读该数据,如 address、boolean、float、signed、unsigned、signed char、unsigned char、packed、UTF等 | | DW_AT_byte_size | 需要占据多少字节 | | DW_AT_bit_size | 实际使用多少bits | | DW_AT_bit_offset | 实际使用的bitsize个bits在bytesize个字节中的起始偏移量 | | DW_AT_sibling | 指向兄弟DIE,DWARF信息生成时,如果认为有必要快速跳过children而扫描Siblings更重要时,会生成该属性 | 属性 `DW_AT_encoding` 是不同基本数据类型差异的体现,它指示了不同基本数据类型,应该如何编码、如何解读。下面是几个示例,帮助大家加深理解,对应的语言编译工具链生成DWARF信息的时候参考这个生成即可,调试器读取的时候参考这个读取、解读数据即可。 Figure 2a 定义类型 int 在32位处理器上是4字节有符号数, 图 2b 定义类型 int 在16位处理器上是2字节有符号数。 basetype 图 3 定义类型word是16位有符号数值,但该类型实际占用4字节,但只有高位2个字节被使用,低位2个字节全部为0。 basetype 注意,上图示例取自DWARF v2的官方示例,在DWARF v4中已经废弃了DW_AT_bit_offset,而是用DW_AT_data_bit_offset代替。在DWARF v2、v3中该属性DW_AT_bit_offset用来表示big endian机器上的位字段,对little endian机器无用有点浪费。 ### 复合类型 如Figure 4所示,先来看一个描述有名变量的例子。首先有一个 DW_TAG_variable类型的DIE描述这个变量 x,这个DIE的属性 `DW_AT_name=x` 表示变量名为x,`DW_AT_type=<1>` 则表示该属性是一个类型引用,变量类型由 `<1>`指向的DIE确定。而 `<1>` 这个DIE表示是一个大小为4字节的有符号整数。最终我们可以得知,这里定义了一个类型为4字节有符号整数的变量x。 compositetype 接下来我们再继续看,通过组合这些基本数据类型,我们可以构造更复杂的复合数据类型。Figure 5中,定义了一个变量px,其类型通过 `DW_AT_type=<2>` 引用另一个编号为 `<2>`的DIE。 编号为 `<2>` 这个DIE的TAG为 `DW_TAG_pointer_type`,说明它是一个指针类型,该DIE内部又通过 `Attribute DW_AT_type=<3>`引用另一个描述数据类型的编号为 `<3>`的DIE,`<3>`这个DIE的TAG为 `DW_TAG_base_type`,表示它是一个基本数据类型,具体为4字节有符号整数。基本数据类型不依赖任何其他类型,分析结束。 这样,一连串分析下来,最终我们可以确定变量px是一个4字节位宽的指针,这个指针指向1个4字节的有符号整数int。 compositetype 其他数据类型也可以通过链接多个DIE(DW_TAG…+DW_AT_type…)来定义一个新的数据类型,例如可以在DW_TAG_pointer_type基础上扩展来支持描述C++的引用类型,或者在基本类型基础上扩展来支持描述Go uintptr的,进而Unsafe.Pointer。 > ps:**关于引用属性的取值的一点补充?** > > 这里为了好理解,引用DIE时使用了一个自然编号,真实DWARF数据存储中,这里不是编号,而是一个偏移量(被引用数据类型的DIE的位置距离包含它的编译单元开头的偏移量)。 ### 数组类型 **DW_TAG_array_type,结合一些相关attributes共同来描述数组。** 数组对应的DIE,该DIE包含了这样的一些属性来描述数组元素: - **DW_AT_ordering**:描述数组是按照“**行主序**”还是按照“**列主序**”存储,如Fortran是按照列主序存储,C和C++是按照行主序存储。如果未指定该属性值,则使用DW_AT_language指定编程语言的默认数组排列规则; - **DW_AT_type**:描述数组中各个元素的类型信息; - **DW_AT_byte_stride/DW_AT_bit_stride**:如果数组中每个元素的实际大小和分配的空间大小不同的话,可以通过这两个属性来说明; - **数组的索引值范围**,DIE中也需要通过指定最小、最大索引值来给出一个有效的索引值区间。这样DWARF就可以既能够描述C风格的数组(用0作为数组起始索引),也能够描述Pascal和Ada的数组(其数组最小索引值、最大索引值是可以变化的)。 数组维度一般是通过换一个TAG为**DW_TAG_subrange_type**或者**DW_TAG_enumeration_type**的DIE来描述。 - 其他; 通过上述这些属性以及描述数组维度相关的DIE,来共同明确描述一个数组。 举个例子,我们创建一个数组,然后编译构建 `go build -o main -gcflags 'all=-N -l' main.go`, 然后使用作者提供的工具dwarfviewer来可视化文件中DIE之间的依赖关系。 ```go $ cat main.go package main func main() { var nums [16]int _ = nums } ``` 运行 `dwarfviewer -file main -webui`,然后搜索main.main并一级一级展开nums相关的DIE定义,作者已经将array相关的重要信息在截图中进行了标注,结合上述文字描述应该不难理解。 dwarf_desc_array ### Struct, Classe, Union, and Interface 大多数编程语言都允许通过组合多种不同的数据类型来定义一个新的数据类型,DWARF中也需要支持对这种能力的描述,因此DWARF中定义了下面的TAG类型: - **DW_TAG_structure_type**,描述结构体struct; - **DW_TAG_class_type**,描述类class; - **DW_TAG_union_type**,描述联合union; - **DW_TAG_interface_type**,描述interface; struct允许组合多个不同类型的成员。C语言中联合union也允许这样做,但是不同的成员共享相同的存储空间。C++ struct相比C语言又增加了一些特性,允许添加一些成员函数。C++中class和Java中interface、class有相似之处,但也有不同。另外,不同语言一般都有相似的组合数据类型,只是取的名字可能不同,比如C++中叫class和class members(类和类成员),在Pascal中叫Record和Fields(记录和字段)。DWARF抽象这些描述时也要选个合适的名字,DWARF中采用了C++中的术语。描述class的DIE是描述该class members的诸多DIE的父DIE,每个class都有一个名字和可能的属性(成员)。如果class实例的大小在编译时可以确定,描述class的DIE就会多一个属性DW_AT_byte_size。class及class member的描述与基本数据类型描述的方式并没有太大的不同,可能会增加一些其他的描述信息,如class member的访问修饰符。C\C++中也支持结构体位字段,即struct中多个成员可以共享同一个字节,只是不同的成员可以使用位数不同的相邻的比特。需要通过多个属性来描述,DW_AT_byte_size描述结构体实际占用多少个字节,属性DW_AT_bit_offset和DW_AT_bit_size描述位字段实际占用哪些比特,从第几个bit开始存储,一共占用多少个比特。 由于这几种类型所描述程序构造的差异,DWARf要为其定义对应的一些attributes才能精确地描述不同语言中的这些共性、差异性。由于篇幅原因,就不一一列举和对比了。感兴趣的话,您可以参考DWARF v4的 $5.5章节来详细了解。 这里我们来看一个go中进行类型定义的示例,这里定义了一个结构体类型Student,并且通过匿名嵌套定义了一个CollegeStudent,然后main函数中创建二者的变量。 ```go $ cat main.go package main type Student struct { Name string Age int Sex int Grades map[string]float32 } type CollegeStudent struct { Student Clubs []string } func main() { var s1 Student var s2 CollegeStudent _ = s1 _ = s2 } ``` 注意编译构建的时候仍然关掉内联和其他优化,我们来可视化看下生成的DIE信息,作者已经在途中通过图例进行了标注,结合前面的介绍,也是很容易理解的: dwarf_desc_struct ### 变量定义 **DW_TAG_variable,用来描述变量**,前面给出的示例中已经多次进行了提及。变量通常非常简单,变量有变量名(DW_AT_name),程序中使用变量名来代指变量在内存或者寄存器中的值。变量的类型描述了值的类型以及访问修饰(如只读const)。另外需要注意的是,DWARF中将variables分成3类:constants(常量)、formal parameters(函数形参)、variables(变量)。这里我们先只关注variables就可以,后续有的是机会遇到constants、formal parameters,那时候再介绍不迟。 对变量进行区分的两个要素是变量的**存储位置**和**作用域:** - **变量的存储位置**:一个变量可以被存储在全局数据区(.data section)、栈、堆或者寄存器中; - **变量的作用域**:描述了它在程序中什么时候是可见的,某种程度上,变量作用域是由其声明时的位置确定的。DWARF中通过三元组(文件名,行号,列号)对变量声明位置进行描述; 在我们进行调试时,我们既然能拿到变量在内存中的位置信息,那我们就可以通过PTRACE_PEEKDATA操作读取到对应的数据,要读取多少数据,以及如何解释这些数据,就需要再参考这里变量引用的类型信息。有了数据,有了类型,有反射变成经验的同学,自然就会觉得轻车熟路了,还有啥玩不转的?对吧。 前面给出的demo截图中,眼睛敏锐的读者应该发现问题了,变量地址值显示的更像是乱码?没错,DW_AT_location属性的值是一个byte数组,在dwarfviewer server端回包时会将其先进行base64编码,返回给前端后就直接展示出来了,所以地址值显示为了一个诡异的字符串。那么直接显示这个byte数组可以吗?不行的。这就是接下来要介绍的内容,该byte数组中存储的并不是一个地址值,而是一个位置表达式,是一串地址的计算规则。我们要执行这里的计算规则,才能得到有效地址。我们对dwarfviewer逻辑进行了调整,使得它可以将上述byte数组,转换成可读的位置表达式。 变量位置demo:下面示例及截图中正常展示了变量的位置信息,在我们的例子中,变量的位置表达式是基于fbreg的寻址规则,这里先不展开,介绍完位置表达式内容之后大家自然会明白。 ```go package main func main() { var a string = "helloworld" var b int = 100 _ = a _ = b } ``` dwarf_desc_variable 变量作用域demo:我们还需要展示一个情况,就是在不同作用域中定义变量的问题,微调下示例代码,再执行下测试。 ```go package main func main() { var a string = "helloworld" var b int = 100 _ = a _ = b { var a string = "helloworld2" _ = a } } ``` 然后我们再来可视化生成的DIE信息,看下有什么不同,我们注意到作用域的表示是通过 `DW_TAG_lexical_block` 来实现的: dwarf_desc_variable ### 了解更多 - Debugging Using DWARF (2012), https://www.scribd.com/document/403070136/Debugging-Using-DWARF-2012 - Types of Declarations, 请参考 DWARF v2 章节3.2.2.1 和 章节3.2.2.2; - Accessibility of Declarations, 有些语言提供了对对象或者其他实体的访问控制,可以通过指定属性 DW_AT_accessibility 来实现, 可取值 DW_ACCESS_public, DW_ACCESS_private, DW_ACCESS_protected; - Visualbility of Declarations, 指定声明的可见性,声明是否在其它模块中可见,还是只在当前声明模块中可见,可以通过指定属性 attribute DW_AT_visualbility 来实现, 可取值 DW_VIS_local, DW_VIS_exported, DW_VIS_qualified; - Virtuality of Declarations, C++提供了虚函数、纯虚函数支持,可以通过指定属性 DW_AT_virtuality 来实现, 可取值 DW_VIRTUALITY_none, DW_VIRTUALITY_virtual, DW_VIRTUALITY_pure_virtual; - Artificial Entries, 编译器可能希望为那些不是在程序源码中声明的对象或类型添加调试信息条目,举个例子,C++中类成员函数(非静态成员),每个形式参数都有一个形参描述条目,此外还需要多加一个描述隐式传递的this指针; - Declaration coordinates, 每个描述对象、模块、函数或者类型的DIE(调试信息条目)都会有下面几个属性 DW_AT_decl_file、DW_AT_decl_line、DW_AT_decl_column,这几个属性描述了声明在源文件中出现的位置; ### 本节小结 本文介绍了DWARF中如何描述数据和数据类型。主要内容包括: - DWARF通过基本类型的组合来构建复杂的数据类型,以适应不同编程语言和平台的需求; - 使用DW_TAG_base_type及其属性来描述基本数据类型,包括类型名称、编码方式、大小等信息; - 使用DW_AT_type属性来引用使用到的类型DIE; - 使用DW_AT_byte_size, DW_AT_bit_size, DW_AT_bit_offset来表示分配的字节数、实际使用的bits数以及偏移量; - 变量的作用域通过DW_TAG_lexical_block来表示,可以准确描述不同作用域中的同名变量; - 通过DW_AT_location属性来描述变量的位置信息,它存储了一个位置表达式而不是直接的地址值; 这种灵活的类型描述机制使得DWARF能够精确地表达各种编程语言中的数据类型,并支持调试器正确地访问和显示变量信息。同时通过位置表达式和作用域的描述,也能准确地定位和区分变量。下一节我们将详细介绍下DW_AT_location位置信息是如何设计的。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf 9. dwarfviewer, https://github.com/hitzhangjie/dwarfviewer ================================================ FILE: book/8-dwarf/4-die/2-desc-locations.md ================================================ ## 位置数据 调试信息必须为调试器提供一种方法,使其能够查找程序变量的位置、确定动态数组和字符串的范围,以及能找到函数栈帧的基地址或函数返回地址的方法。此外,为了满足最新的计算机体系结构和优化技术的需求,调试信息必须能够描述对象的位置,还需要注意的是,该对象的位置可能会在对象的生命周期内发生变化。 DWARF提供了一种非常通用的机制描述如何确定变量在内存中的实际位置,就是通过属性**DW_AT_location**,该属性允许指定一个操作序列,来告知调试器如何确定变量的地址。 ### 多种寻址方式 下面是DWARF v2官方示例中的demo,展示了如何使用属性 `DW_AT_location`来定位变量的地址。变量可以定义在寄存器中、内存中(堆、栈、全局存储区),对应的寻址规则也有差异(兼顾寻址的正确性、效率等)。 location Figure 7这个示例中: - 变量b定义在寄存器中, `DW_AT_location = (DW_OP_reg0)`,直接存储在reg0对应的寄存器中; - 变量c存储在栈上,`DW_AT_location = (DW_OP_fbreg: -12)`,EA=fbreg-12,fbreg (framebase register),表示该变量位置在当前 `栈基址-12` 这个位置; - 变量a存储在固定地址(.data section中),`DW_AT_location = (DW_OP_addr: 0)`,存储在.data开头; 描述位置信息的方法,主要可以分为两类: - **位置表达式(Location Expressions)**,是与语言无关的寻址规则表示形式,它是由一些基本构建块、操作序列组合而成的任意复杂度的寻址规则。 只要对象的生命周期是静态的(static)或与拥有它的词法块相同,并且在整个生命周期内都不会移动,它们就足以描述任何对象的位置。 - **位置列表(Location Lists)**,用于描述生命周期有限的对象或在整个生命周期内对象的地址可能会发生变动的对象。 ### “位置表达式”描述单一位置 变量在整个生命周期内,其位置都不会发生变化,此时只需要一个单一的位置表达式即可,用DWARF表达式直接描述变量的存储位置,比如“在寄存器X”或“在栈偏移量Y处”。位置表达式由0个或者多个位置操作组成,可以划分为“寄存器名” 和 “地址操作”两种寻址方式。 > ps: 如果没有位置运算表达式,则表示该对象在源代码中存在、在目标文件中不存在,被编译器给优化掉了。 #### 寄存器名 寄存器名(寄存器号),始终是单独出现的,并指示所引用的对象包含在特定寄存器中。请注意,寄存器号是DWARF中特定的数字到给定体系结构的实际寄存器的映射。`DW_OP_reg${n} (0<=n<=31)` 操作编码了32个寄存器, 该对象地址在寄存器n中. `DW_OP_regx` 操作有一个无符号LEB128编码的操作数,该操作数代表寄存器号。 #### 地址操作 地址操作是针对内存地址的计算规则,所有位置操作的操作码(opcode)和操作数(operand),被编码在同一个操作流里,每个操作码后跟0个或多个操作数,操作数的数量由操作码决定。类似这样 `[opcode1][operand1][operand2][opcode2][opcode3][operand3]...`。 ps:前面查看变量地址时,我们介绍过DW_AT_location是一个byte数组,解码时是从这个byte数组中一起解码的。 每个寻址操作都表示 "**基于栈架构机器上的后缀操作**",这里的栈,通常称为 “位置栈(Location Stack)” 或 “寻址栈(Addressing Stack)”: - 栈上每个元素,是一个目标机器上的地址的值(或表达式计算过程中的中间结果); - 执行位置表达式之后,栈顶元素的值就是计算结果(对象的地址,或者数组长度,或者字符串长度)。 **位置表达式中的地址计算方式,主要包括如下几种:** 1. **寄存器寻址** 寄存器寻址方式, 计算目标寄存器中的值与指定偏移量的和,结果push到栈上: - DW_OP_fbreg \$offset, 计算栈基址寄存器 (rbp)中的值 与 偏移量 $offset的和; - DW_OP_breg\${n} \${offset}, 计算编号n的寄存器中的值 与 偏移量$offset(LEB128编码)的和; - DW_OP_bregx \${n} \${offset}, 计算编号n(LEB128编码)的寄存器中的值 与 偏移量 $offset(LEB128编码)的和; 2. **栈操作** 以下操作执行后都会push一个值到Location Stack上: - DW_OP_lit\${n} (0<=n<=31), 编码一个无符号字面量值\${n}; - DW_OP_addr, 编码一个与目标机器匹配的机器地址; - DW_OP_const1u/1s/2u/2s/4u/4s/8u/8s, 编码一个1/2/4/8 字节 无符号 or 有符号整数; - DW_OP_constu/s, 编码一个 LEB128 无符号数 or 有符号整数. 以下操作会操作Location Stack,栈顶索引值为0: - DW_OP_dup, 复制栈顶entry并重新入栈; - DW_OP_drop, 弹出栈顶entry; - DW_OP_pick, 使用1字节索引值\${index},从栈中根据\${index}找到对应entry并重新入栈; - DW_OP_over, 复制index==2的entry并重新入栈; - DW_OP_swap, 指定两个索引值index1\index2,交换这两个索引值对应的entries; - DW_OP_rot, 旋转滚动栈顶的的3个entries; - DW_OP_deref, 弹栈获取到的值作为有效地址,从这个地址处读取sizeof(ptrOfTargetMachine)大小的数据,并将数据入栈; - DW_OP_deref_size, 类似于DW_OP_deref,不同之处在于,要读取的数据量由1-byte操作数来指定, 然后读取到的数据入栈前将被0填充到sizeof(ptrOfTargetMachine)大小; - DW_OP_xderef & DW_OP_xderef_size, 类似于DW_OP_ref,不同之处在于,扩展了解引用的机制。解引用时, 弹出的栈顶entry数据作为地址;继续弹栈得到次栈顶数据作为地址空间标志符。执行一点计算得到有效地址,并从中读取数据,并入栈; 3. **算术和逻辑运算** DW_OP_abs, DW_OP_and, DW_OP_div, DW_OP_minus, DW_OP_mod, DW_OP_mul, DW_OP_neg, DW_OP_not, DW_OP_or, DW_OP_plus, DW_OP_plus_uconst, DW_OP_shl, DW_OP_shr, DW_OP_shra, DW_OP_xor, 这些操作工作方式类似,都是从栈里面pop操作数然后计算,并将结果push到栈上。 4. **控制流操作** 以下操作提供对位置表达式流程的简单控制: - 关系运算符,这六个运算符分别弹出顶部的两个堆栈元素,并将顶部的第一个与第二个条目进行比较,如果结果为true,则push值1;如果结果为false,则push值0; - DW_OP_skip,无条件分支,其操作数是一个2字节常量,表示要从当前位置表达式跳过的位置表达式的字节数,从2字节常量之后开始; - DW_OP_bra,条件分支,此操作从栈上pop一个元素,如果弹出的值不为零,则跳过一些字节以跳转到位置表达式。 要跳过的字节数由其操作数指定,该操作数是一个2字节的常量,表示从当前定位表达式开始要跳过的位置表达式的字节数(从2字节常量开始); 5. **特殊操作** DWARF v2中有两种特殊的操作(DWARF v4中是否有新增,暂时先不关注): - DW_OP_piece, 许多编译器将单个变量存储在一组寄存器中,或者部分存储在寄存器中,部分存储在内存中。 DW_OP_piece提供了一种描述特定地址位置所指向变量的哪一部分、该部分有多大的方式; - DW_OP_nop, 它是一个占位符,它对位置堆栈或其任何值都没有影响; > ps: 对于结构体成员地址的计算,在执行位置表达式之前,需要先将包含该成员的结构体的起始地址push到栈上。 #### 操作示例 上面提到的寻址操作都是些常规描述,下面是一些示例。 - 栈操作示例 `location rules` - 位置表达式示例 以下是一些有关如何使用位置运算来形成位置表达式的示例。 ![img](assets/clip_image008.png) ### “位置列表”可描述多个位置 如果一个对象的位置在其生命周期内可能会发生改变,或者生命周期有限,就可以使用位置列表代替位置表达式来描述其位置。实际上,就是用一组区间(PC范围)和对应的DWARF表达式,描述变量在不同代码区间时的存储位置。什么情况下会发生“变量的存储位置在不同的代码区间时会发生变化”呢?比如由于优化、寄存器分配、变量溢出到栈等。 举个例子,比如变量b在函数的前半段保存在寄存器rbx,后半段被溢出到栈上(比如rbp-8),此时就应该用位置列表来跟踪对象地址的变化。 ``` [0x100, 0x120): DW_OP_reg3 // 在0x100到0x120之间,b在rbx [0x120, 0x140): DW_OP_fbreg -8 // 在0x120到0x140之间,b在rbp-8 ``` > ps: 有读者会想到有些编程语言里面的移动式GC,那个跟这个完全是两码事,也不能用这种方式来解决。 位置列表 (.debug_loc section) 中的每一项包括: - 起始地址,相对于引用此位置列表的编译单元的基址,它标记该位置有效的地址范围的起始位置; - 结束地址,它还是相对于引用此位置列表的编译单元的基址而言的,它标记了该位置有效的地址范围的结尾; - 一个位置表达式,它描述PC在起始地址和结束地址指定的范围内时,对象在内存中的位置表达式; 位置列表以一个特殊的list entry标识列表的结束,该list entry中的起始地址、结束地址都是0,并且没有位置描述。 DWARF v5会将.debug_loc和.debug_ranges替换为.debug_loclists和.debug_rnglists,从而实现更紧凑的数据表示,并消除重定位。 ### 位置信息的生成 DWARF 位置表达式的设计确实相对直观,编译器在生成调试信息时,能够根据变量的存储安排(如寄存器、栈、全局存储区等)直接生成对应的位置表达式。具体来说: 1. 编译器已知变量位置:编译器在编译过程中会进行语法分析、符号表管理、存储分配等工作,因此它能够确定每个变量的存储位置(例如,局部变量在栈上的偏移量、全局变量在 .data 段的地址、寄存器分配等)。 2. 位置表达式的生成:编译器可以根据这些已知信息,直接生成 DWARF 位置表达式。例如: * 如果变量 a 存储在全局数据段,编译器会生成 DW_OP_addr: 0 这样的位置表达式。 * 如果变量 b 存储在寄存器 reg0 中,编译器会生成 DW_OP_reg0。 * 如果变量 c 存储在栈上,编译器会生成 DW_OP_fbreg: -12,表示栈基址寄存器(如 rbp)减去偏移量 12。 3. 位置表达式的简单性:DWARF 位置表达式的设计是基于栈的后缀操作,这种设计使得表达式能够灵活地描述复杂的地址计算,同时保持简洁。编译器只需要根据变量的存储安排,生成对应的操作码和操作数即可。 4. 当然了,编译器也知道做了哪些优化、寄存器分配、什么情况下会发生变量溢出到栈的情况,自然也可以知道PC处于不同范围时应该生成不同的位置信息(位置列表); 这个过程属于编译器的工作范畴,并没有那么复杂,这部分了解到这里就可以了。 ### 本节小结 本文介绍了DWARF中的位置描述机制。我们了解到DWARF使用位置表达式和位置列表两种方式来描述变量的位置信息: - 位置表达式是一种基于栈的后缀表达式,通过一系列操作来计算出变量的实际地址 - 位置列表则用于描述变量在其生命周期内可能发生变化的位置信息,通过PC范围和对应的位置表达式来表示 这种灵活的位置描述机制使得调试器能够准确地定位和访问程序中的变量,即使在编译优化、寄存器分配等情况下也能正确工作。编译器在生成调试信息时,能够根据变量的存储安排直接生成对应的位置表达式或位置列表。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf 9. dwarfviewer, https://github.com/hitzhangjie/dwarfviewer ================================================ FILE: book/8-dwarf/4-die/3-desc-code.md ================================================ ## 描述可执行代码 前面介绍了DIE如何描述数据和类型的,也了解了如何对数据位置进行描述,这个小节继续看下如何描述可执行代码。这部分我们主要介绍下对函数和编译单元的描述。 ### 描述函数 不同编程语言、开发者对函数的叫法也不完全一致,带返回值的函数(function)和不带返回值的例程(subroutine),我们将其视作同一个事物的两个不同变体,DWARF中使用DW_TAG_subprogram来描述它们。该DIE具有一个名称,一个三元组表示的源代码中的位置(DW_AT_decl_file, DW_AT_del_line:),还有一个指示该子程序是否在外部(编译单元)可见的属性(DW_AT_external)。 > 在不同的编程语言中,函数有不同的术语表示,如routine, subroutine, subprogram, function, method or procedure,参考:https://en.wikipedia.org/wiki/Subroutine。这里不深究细节上的差异,明白DW_AT_subprogram是用来描述函数的就可以。 #### 函数地址范围 函数DIE具有属性 `DW_AT_low_pc`、`DW_AT_high_pc`,以给出函数占用的内存地址空间的上下界。 在某些情况下,函数的内存地址可能是连续的,也可能不是连续的。如果不连续,则会有一个内存范围列表。一般DW_AT_low_pc的值为函数入口点地址,除非明确指定了另一个地址。 #### 函数返回值类型 函数的返回值类型由属性 `DW_AT_type` 描述。 如果没有返回值,则此属性不存在。如果在此函数的相同范围内定义了返回类型,则返回类型DIE将作为此函数DIE的兄弟DIE。 > ps: 实际上用Go进行测试,会发现Go编译工具链并没有使用DW_AT_type来作为返回值类型,因为Go支持多返回值,仅靠这一个属性是不够的。所以Go中采用了其他的解决方案,下面会介绍到。 #### 函数形参列表 函数可能具有零个或多个形式参数,这些参数由DIE `DW_TAG_formal_parameter` 描述,这些形参DIE的位置被安排在函数DIE之后,并且各形参DIE的顺序按照形参列表中出现的顺序,尽管参数类型的DIE可能会散布。 通常,这些形式参数存储在寄存器中。 #### 函数局部变量 函数主体可能包含局部变量,这些变量由DIE `DW_TAG_variables` 在形参DIE之后列出。通常这些局部变量在栈中分配。 #### 词法块 大多数编程语言都支持词法块,函数中可能有一些词法块,可以用DIE `DW_TAG_lexcical_block` 来描述。 词法块也可以包含变量和词法块DIE。 #### 示例说明 下面是一个描述C语言函数的示例,可以看到有个名字为strndup的类型为DW_TAG_subprogram的 `DIE <5>`,这个就是DIE是描述函数strndup的DIE;这个C函数的返回值类型由DW_AT_type属性最终确定为*char,1个4字节的指针;继续看下去我们看到了两个形参s、n各自对应的类型为DW_TAG_formal_parameter的DIEs,其中s最终由属性可以确定是const char *类型,而n是unsigned int类型,s、n在内存中的位置分别为fbreg+0,fbreg+4的位置。 dwarf_desc_code 生成的DWARF调试信息如下所示: dwarf_4_func 该示例取自DWARF v4中章节5.3.3.1.1~5.3.3.1.6,这个示例并不复杂,作者也已经对关键信息做了高亮,结合前面讲的内容,读者理解起来应该也不困难。如果您确实没看懂,可以看下DWARF v4中相关章节的详细描述。 ### 编译单元 大多数程序包含多个源文件。 在生成程序时,每个源文件都被视为一个独立的编译单元,并被编译为独立的*.o文件(例如C),然后链接器会将这些目标文件、系统特定的启动代码、系统库链接在一起以生成完整的可执行程序 。 > 注:go中就不是每个源文件作为一个编译单元,而是将package作为一个编译单元。 DWARF中采用了C语言中的术语“编译单元(compilation unit)”作为DIE的名称 `DW_TAG_compilation_unit`。 DIE包含有关编译的常规信息,包括源文件对应的目录和文件名、使用的编程语言、DWARF信息的生产者,以及有助于定位行号和宏信息的偏移量等等。 如果编译单元占用了连续的内存(即,它会被装入一个连续的内存区域),那么该单元的低内存地址和高内存地址将有值,即属性:低地址DW_AT_low_pc,高地址DW_AT_high_pc。 这有助于调试器更轻松地确定特定地址处的指令是由哪个编译单元生成的。如果编译单元占用的内存不连续,则编译器和链接器将提供代码占用的内存地址列表。 每个编译单元都由一个“**公共信息条目CIE(Common Information Entry)**”表示,编译单元中除了CIE以外,还包含了一系列的**帧描述条目FDE(Frame Description Entrie)**。 ### Go多值返回 最后,关于Go的一点特殊说明,在描述返回值类型时,Go并不是使用属性DW_AT_type。 下图展示的是C语言中采取的方式,C语言编译器采取了这里DWARF标准推荐的方式,如**形参列表通过DW_TAG_former_parameter来说明,返回值类型通过DW_AT_type来说明,如果没有返回值则无此属性**。 dwarf_desc_func 但是,Go语言和C相比有特殊之处,**Go需要****要支持多值返回**,所以仅用DW_AT_type无法对返回值列表充分描述。我们可以写测试程序验证,golang v1.15中并没有使用DWARF规范中推荐的DW_AT_type来说明返回值类型。golang中对返回值的表示,和参数列表中参数一样,仍然是通过DW_TAG_formal_parameter来描述的,但是会通过属性DW_AT_variable_parameter来区分参数属于形参列表或返回值列表,为0(false)表示是形参,为1(true)表示是返回值。 ### 本节小结 本节介绍了DWARF如何对可执行代码相关的程序构造进行描述,如函数、编译单元等,最后指出了Go语言函数支持多值返回时这里的返回值描述的特殊之处。读到这里,相信读者已经对DWARF如何描述可执行程序有了更深入的认识。 ================================================ FILE: book/8-dwarf/4-die/4-encoding.md ================================================ ## DIE数据编码 DWARF中的所有调试信息条目(DIE, Debugging Information Entry),它们可以用来描述程序中的数据、类型、代码,在之前内容中我们已经见识过了描述不同类型程序构造的DIE Tags、Attributes。DIE之间还存在两种可能的链接或者引用关系:1)Children关系:表示DIE与其子DIE之间的父子关系;2)Siblings关系:表示同级DIE之间的兄弟关系。这种结构使得DWARF能够完整地描述程序的调试信息,如果要不加优化完整地存储这样的结构,也会面临数据冗余和存储空间问题。 所以本节我们来看看DIE数据的编码和存储方式。 ### 数据压缩的必要性 DWARF调试信息通常包含大量重复和冗余数据,例如: - 相同类型的变量可能有相同的属性列表 - 相似的函数可能有相似的结构信息 - 大量的类型信息可能被多次引用 为了减少存储空间占用,DWARF提供了几种数据压缩的措施。 ### 措施1:树前序遍历扁平化 **原理**: - 采用前序遍历的方式访问DIE树 - 按照访问顺序将DIE依次存储 - 不再显式存储DIE之间的链接关系 **实现方式**: - 使用特殊属性来维护DIE之间的关系 - `DW_AT_sibling`:指向下一个兄弟DIE - `DW_AT_type`:指向类型DIE - 其他属性:根据需要维护其他关系 **优势**: - 消除了显式的链接指针 - 简化了数据存储结构 - 便于顺序访问和解析 ### 措施2:缩写表机制(Abbreviation Table) **原理**: - 将DIE的tag值和attributes类型存储在缩写表中 - DIE中只存储缩写表的索引和属性值 - 通过复用相同的tag和属性列表来减少存储空间 **缩写表结构**: 每个缩写包含: - tag值:DIE的类型 - has_children标志:指示该DIE是否有子DIE - 属性列表:包含属性类型和值类型 **示例**: 假设有多个变量DIE,它们具有相同的tag(DW_TAG_variable)和属性列表(DW_AT_name, DW_AT_type),但属性值不同: - 在缩写表中存储一次tag和属性列表 - 每个DIE只存储缩写表索引和具体的属性值 - 大大减少了重复数据的存储 **图 9 缩写表示例**: ![img](assets/clip_image011.png) ### 措施3:跨编译单元引用 DWARF v3引入了一种允许跨编译单元引用DWARF数据的机制: - 允许一个编译单元引用另一个编译单元中的DIE - 通过特殊的引用属性实现 - 这种方式不常用,但在某些场景下可以进一步减少数据冗余 ### 总结 DWARF的数据编码和压缩策略主要针对DIE树结构,1)扁平化存储减少链接开销 2)缩写表机制减少重复数据 3)跨编译单元引用提供额外的优化空间。通过这些策略的共同作用,显著减少了调试信息在二进制文件中的存储空间占用,同时保持了调试信息的完整性和可访问性。 ================================================ FILE: book/8-dwarf/4-die/README.md ================================================ ## DIE详细介绍 跟其他一些标准需要不断演进一样,DWARF也经历了DWARF v1到DWARF v5的发展阶段。随着DWARF调试信息的完善,以及高级语言进一步抽象、进化,为了更好更高效地对高级语言进行描述,DWARF标准中的Tag枚举值、Attribute枚举值也在慢慢增加。以Tag枚举值为例,DWARF v1中定义了33个Tag枚举值,v2增加到了47个,v3增加到了57个,v4增加到了60个,最新的v5增加到了68个。Attributes当然也存在类似的扩展、数量增加的情况。 但是增加Tags、Attributes不代表DWARF的理解就变得更复杂了,这正是其“良好扩展性”的体现。只是因为篇幅原因,我们先拿DWARF v2中的Tag、Attributes进行展示,让大家有个直观认识后,后面示例中再与当前go编译工具链使用最多的DWARF v4、v5内容进行对齐。以免必要的内容还未介绍到位,大家就已经淹没在了不同版本的细节变迁中。 ### DIE结构及组织 DWARF使用一系列调试信息条目(DIE, Debugging Information Entry)来对程序构造进行描述,每个DIE都由一个tag以及一系列attributes构成: - tag指明了该DIE描述的程序构造的类型,如编译单元、函数、函数参数及返回值、变量、常量、数据类型等; - attributes定义了该DIE的一些具体属性、特征,如变量的名字DW_ATTR_name、变量所属的数据类型DW_ATTR_type等; DIEs之间的关系,可能有兄弟节点(sibling DIEs,由attribute DW_ATTR_type、DW_ATTR_sibling引用),也可能有子节点(Children,如编译单元中包含了一系列函数定义,每个函数定义又包括了入参、出参)。如果进考虑Children关系的话,DIEs构成了一棵树(tree);如果也把Sibling关系考虑进去的话,就构成了一个图(graph)。 #### DIE Tags Tag,其枚举值以 `DW_TAG_` 开头,它指明了DIE描述的程序构造所属的类型,下面表格中整理了DWARF v2中定义的Tag枚举值,大部分可以望文生义的方式知道它是描述什么的,但是要详细了解的话,特别是不同Tag类型的DIE可以使用的Attributes,建议阅读DWARF标准进行更深入的了解。 ![img](assets/clip_image001.png) #### DIE Attributes Attribute,其枚举值以 `DW_AT_` 开头,它表示了DIE的一些属性、特征信息,进一步补充了DIE要描述的程序构造的信息。 不同attributes的值类型可能也不同,可以是一个常量(如函数名称)、变量(如函数的开始地址)、对另一个DIE的引用(如函数返回值对应的类型DIE)等等。即使确定了是哪种类型的值,它的编码方式也可能是有差异的,如,常量数据有多种表示形式(如固定为1、2、4、8字节长度的数据,或者可变长度的数据)。 >ps: Attribute的任何类型实例的特定表示,都与属性名称一起被编码,以方便更好地理解、解释DIE的含义。 下表列出了DWARF v2中定义的attributes: ![img](assets/clip_image002.png) attribute的值,可以划分为如下几种类型: 1. **Address**, 引用被描述程序的地址空间的某个位置; 2. **Block**, 未被解释的任意数量的字节数据块; 3. **Constant**, 1、2、4、8字节未被解释的数据,或者以LEB128形式编码的数据; 4. **Flag**, 指示属性存在与否的小常数; 5. **lineptr**, 引用存储着行号信息的DWARF section中的某个位置; 6. **loclistptr**, 引用存储着位置列表的DWARF section中的某个位置,某些对象的内存地址在其生命周期内会发生移动,需要通过位置列表来进行描述; 7. **macptr**, 引用存储着macro信息的DWARF section中的某个位置; 8. **rangelistptr**, 引用存储着非相邻地址区间信息的DWARF section中的某个位置; 9. **Reference**, 引用某个描述program的DIE; 根据被引用DIE所在的编译单元与引用发生的编译单元是否相同,可以划分为两种类型的references: - 第一种引用,被引用的DIE所在的编译单元与当前编译单元是同一个,通过相对于该编译单元起始位置的偏移量来引用该DIE; - 第二种引用,被引用的DIE所在的编译单元可以在任意编译单元中,不一定与当前编译单元相同,通过被引用DIE的偏移量来引用该DIE; 10. **String**, 以'\0'结尾的字符序列,字符串可能会在DIE中直接表示,也可能通过一个独立的字符串表中的偏移量(索引)来引用。 #### 示例描述 下面是一个简单的C程序对应的DIEs数据展示,我们看到最顶层是一个编译单元DIE(表示源文件),它包含了一个Subprogram类型的Child DIE(表示main函数),该Subprogram类型的DIE的返回值描述对应着一个int类型的BaseType类型的DIE。 dwarf dies ### DIE的分类 根据描述信息的不同,可以将所有的DIEs划分为两大类: 1. 描述 **数据 和 类型** 的; 2. 描述 **函数 和 可执行代码** 的; 一个DIE可以有父、兄弟、孩子DIEs,DWARF调试信息可以被构造成一棵树,树中每个节点都是一个DIE,多个DIE组合在一起共同描述编程语言中具体的一个程序构造(如描述一个函数的定义)。 描述不同类型的程序构造,显然需要不同的Tag类型的DIE,而不同Tag的DIE所使用的Attributes也必然是不同的。想要更好地了解如何对特定语言的程序构造如何进行描述,就需要了解DWARF标准中的推荐做法,以及特定编程语言编译工具链中实际采用的描述方法。 从学习角度来说,这部分,我们也不需要真的深入go编译工具链的DWARF生成细节,这个比较费时费力,我们只需要写测试代码,然后使用合适的工具观察它具体包含哪些Tag、哪些Attributes就可以了。 在后面的章节,我们会介绍DIE是如何描述程序中的数据和类型的,然后再介绍是如何描述函数和可执行代码的。 ### DIE的存储位置 调试信息条目存储在.debug_info中,DIE可以描述类型、变量、函数、编译单元等等不同的程序构造。DWARF v4中曾经提出将类型相关的描述存储在.debug_types中,初衷是为了避免不同编译单元中存在重复的类型定义,导致linker合并存储到.debug_info时出现重复的DIE信息,解法是每个类型写入独立的section,然后由linker合并、去重后写入.debug_types。即使不写入.debug_types,这也是可以做到的,DWARF v5中已经将类型相关的描述合并入.debug_info,废弃了.debug_types。 see: DWARFv5 Page8: ``` 1.4 Changes from Version 4 to Version 5 The following is a list of the major changes made to the DWARF Debugging 13 Information Format since Version 4 was published. The list is not meant to be 14 exhaustive. 15 • Eliminate the .debug_types section introduced in DWARF Version 4 and 16 move its contents into the .debug_info section. ... ``` 调试信息数据其实是比较大的,如果不经过压缩处理会导致二进制尺寸显著增加。一般会要求编译工具链生成调试信息时进行压缩处理,压缩后的调试信息将存储在:1)目标文件中的".zdebug_"前缀的section中,如未压缩的调试信息条目对应section是.debug_info,那么压缩后将存储在.zdebug_info中;2)也可能仍然存储在".debug_"前缀的section中,但是对应的section的Compressed标记设置为true,并且设置对应的压缩算法,如zlib或者zstd。3)此外,也有些平台上,工具链会将上述调试信息存储在独立的文件或者目录中,如macOS上会写入到对应的 `.dSYM/*` 文件夹中,调试器读取时需要注意这点。 ### 从入门到精通 看到作者提到DWARF已经经历了这么多个版本,并且每个新版本较之旧版本都在不断扩展,大家心里难免有些抓毛,“我能掌握吗?”。 1)大家觉得理解 **“反射(reflection)**”困难吗?反射和这里的DWARF其实有异曲同工之妙。借助反射我们可以在程序运行时,动态理解对象的类型信息,有了类型信息我们也可以动态构建对象、修改对象属性信息。反射技术中使用到的类型信息就是程序运行时的对象的一些跟类型相关的元数据信息,这里的元数据信息的设计和组织面向这一种语言专属的设计。 2)大家觉得理解go runtime的 **.gopclntab** 困难吗?可能大家没有看过相关的实现细节,尽管我们多次提到了go runtime依赖它实现了运行时的调用栈跟踪。这里的.gopclntab也是针对go语言专属的设计。 相比较之下,而DWARF则是面向当前甚至将来所有的高级语言设计的一种描述语言,它也描述了程序的类型定义、对象的类型信息,借助它我们也可以知道内存中某个对象的类型信息,也可以据此构造对象、修改对象,只要我们愿意。行号表、调用栈信息表,也需要针对所有高级语言进行描述,而不能仅仅面向一种语言。当然了,DWARF是面向调试领域的,所以它生成的内容不会在程序执行时加载到内存。 所以,我这么给大家类比一下之后,大家觉得还困难吗?大道至简,道理是相通的,能够不拘泥于形式的灵活运用来解决问题,是我们应该向大师们学习的。 > ps: 为了方便大家学习,我编写了一个DIE可视化工具 [hitzhangjie/dwarfviewer](https://github.com/hitzhangjie/dwarfviewer)。借助此工具,您可以方便地查看ELF文件.debug_info中的DIE信息,包括DIE Tag、Attributes以及Children DIEs、Sibling DIEs。您可以写些简单的代码片段,如包含一个函数,或者一个类型,然后使用此工具对生成的DWARF信息进行对比,以加深理解。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf 9. dwarfviewer, https://github.com/hitzhangjie/dwarfviewer ================================================ FILE: book/8-dwarf/5-other/1-accelerated-access.md ================================================ ## 加速访问(Accelerated Access) ### 更高效地查询 **按名称查找数据对象或函数**:在调试过程中,当tracee暂停执行时,调试器经常需要根据符号名称查找对应的数据对象或函数的调试信息。这些信息可能分布在当前或其他编译单元中。调试器有时只知道程序构造(如变量、函数、类型等)的名称,有时则只有地址。如果仅通过DWARF调试信息条目(DIEs)按名称查找,调试器就需要遍历每个编译单元中的所有DIEs,这是非常耗时的。 **按名称查找类型**:在某些编程语言中(如C++),类型名称必须始终引用相同的具体类型。在这种情况下,编译器可以选择在所有编译单元中消除重复的类型定义。因此,调试器需要一种高效的方法来通过名称快速定位具体的类型定义。与查找全局数据对象类似,这也需要搜索程序中所有编译单元的类型定义相关DIEs。 **按地址查找**:当需要通过地址查找子例程的调试信息时,调试器可以利用编译单元CIE的上下pc属性来快速缩小搜索范围。但是,这些属性仅覆盖了与编译单元条目关联的代码段地址范围。而要通过地址查找数据对象的调试信息,则需要进行完整的搜索。此外,在大型程序中跨不同编译单元搜索调试信息条目时,可能需要访问大量内存页面,这会显著影响调试器的性能。 为了实现更高效的按名称和按地址查找程序实体(包括数据对象、函数和类型),DWARF信息生成器可以额外生成**三种专门的表**。这些表包含了特定编译单元条目所拥有的调试信息条目的相关信息,并且采用了更紧凑的数据格式。 ### 按名称查询(Lookup by Name) 为了支持高效地按名称进行查找,DWARF额外维护了两张表:`.debug_pubnames`和 `.debug_pubtypes`。其中,`.debug_pubnames`描述全局对象和函数的名称,`.debug_pubtypes`描述全局类型。这两张表本质上是名称到具体DIE(调试信息条目)位置的映射表。 理论上,调试器确实可以通过预先分析 .debug_info 中的所有 DIE 信息,自行构建名称到 DIE 的映射表来实现类似的功能。但是 .debug_pubnames 和 .debug_pubtypes section 仍然具有其独特的价值: 1. 避免重复工作 - 这些表已经由编译器优化并生成,调试器无需重复这个耗时的过程 2. 内存效率 - 这些表采用了更紧凑的格式,比完整解析 .debug_info 并在内存中维护映射要节省空间 3. 按需加载 - 调试器可以根据需要只加载这些表的相关部分,而不必一次性加载并解析所有 .debug_info 4. 标准化 - 提供了统一的查询接口,使不同的调试器无需各自实现不同的索引机制 因此,这些 section 作为**“可选的”**优化机制,可以帮助调试器在性能和资源消耗上达到更好的平衡。它们的存在大大提升了调试过程中按名称查找的效率。 `.debug_pubnames` 和 `.debug_pubtypes` section的数据组织,程序中每个编译单元在.debug_pubnames都存在一个对应的单元,每个单元包含: 1. 头部信息(Header) - unit_length: 该单元的总长度(不包括length字段本身) - version: 版本号(2或3) - debug_info_offset: 对应编译单元在.debug_info中的偏移量 - debug_info_length: 对应编译单元在.debug_info中的长度 2. 名称条目(Name Entry)列表,每个条目包含: - offset: DIE在编译单元内的偏移量 - name: 以null结尾的字符串,表示全局对象或函数的名称 3. 结束标记 - offset为0表示该编译单元的名称条目列表结束 这种组织方式使得调试器可以: 快速定位到名称对应的特定编译单元信息,还不需要从头解析完整的DIEs,实现了按需加载。 ### 按地址查询(Lookup by Address) 为了支持高效的按地址查找,DWARF在**.debug_aranges section**中维护了一个专门的加速查询表。该表由一系列可变长度的条目组成,每个条目对应一个编译单元,记录了该编译单元在程序地址空间中所占用的地址范围信息。由于不同的编译单元会占用程序地址空间中互不重叠的区域,通过这个表可以快速定位到包含特定地址的编译单元。 虽然调试器也可以通过预先分析所有编译单元DIE并建立自己的地址范围索引来实现类似功能,但.debug_aranges section仍然有其价值: 1. 它提供了一个标准化的、经过优化的数据结构,避免了每个调试器都要实现自己的索引机制 2. 对于大型程序,预加载和分析所有编译单元DIE会消耗大量内存和时间,而.debug_aranges可以按需加载 3. 编译器在生成这个表时可以应用特定的优化,使其更紧凑和高效 因此,.debug_aranges section作为DWARF标准的一部分,为调试器提供了一个可选但有价值的性能优化机制。 `.debug_aranges` section的数据组织如下,程序中每个编译单元在.debug_aranges都存在一个对应的单元,每个单元包括: 1. 头部信息(Header) - unit_length: 该单元的总长度(不包括length字段本身) - version: 版本号(2) - debug_info_offset: 对应编译单元在.debug_info中的偏移量 - address_size: 目标机器的地址大小(字节数) - segment_size: 段选择器的大小(字节数) 2. 地址范围描述符(Address Range Descriptor)列表,每个描述符包含: - segment: 段选择器(如果segment_size非0) - address: 范围的起始地址 - length: 范围的长度 描述符按起始地址排序,相邻描述符之间不重叠。使用全0的描述符(address和length都为0)表示列表结束。 3. 对齐填充 - 在描述符列表之前添加必要的填充字节,使得第一个描述符的地址按照(2 * address_size)字节对齐 这种组织结构使得调试器可以: 通过二分查找快速定位包含特定地址的编译单元,无需从头解析完整DIEs,实现了按需加载。 ### 本节小结 DWARF v4中提出的上述加速查询的辅助表,是可选的优化方案,意味着编译工具链不一定要生成,调试器也不一定非得读取。DWARF v5中已经将 .debug_pubnames 和 .debug_pubtypes 合并成了 .debug_names。实际情况下,编译工具链不一定生成,即使生成了调试器也不一定使用,大家先了解即可。 ================================================ FILE: book/8-dwarf/5-other/2-lineno-table.md ================================================ ## 行号表(Line Number Table) ### 功能介绍 符号级调试器需要建立源代码位置与机器指令地址之间的映射关系,有了这种映射关系,调试器就可以实现如下操作: - 将源代码位置(文件名:行号)转换为对应的机器指令地址,从而在该地址处精确地设置断点,或者从该地址处开始反汇编等等; - 将当前执行的机器指令地址(PC)反向转换为源代码位置,这样调试时不管是逐指令还是逐语句执行都可以显示出当前源码位置; - 不仅可以实现机器指令级别的逐指令执行,还可以支持源代码级别的逐语句执行,因为很容易就可以确定下一行语句的指令地址; **行号表,记录了可执行程序机器指令地址和源文件中位置之间的映射关系**,为源码级调试提供了大的便利。每个编译单元都有对应的行号表,存储在目标文件的 **.[z]debug_line** section中。.debug_info section中编译单元对应的DIE会引用这里的.debug_line数据(参见DWARF v4规范3.1.1节)。 ### 存储结构 **行号表的结构可以形象地理解为一个矩阵**,其中每一行包含了以下关键信息: line_num_table - 指令地址,对应机器指令在内存中的位置 - 源文件名,指令对应的源代码文件 - 源文件行号,指令对应的源文件中的行号 - 源文件列号,指令对应的源文件中的列号 - 语句标识,标记当前指令是否为源码语句的起始指令 - 词法块标识,标记当前指令是否为词法块的起始指令 - 其他辅助信息 这种矩阵结构建立了机器指令与源代码之间的双向映射关系。当调试器需要在某行源码处设置断点时,可以通过查询行号表找到对应的第一条指令地址;当程序执行出现异常时,也可以根据当前的指令地址反查对应的源码位置,帮助开发者快速定位问题。这种双向映射机制为源码级调试提供了重要支撑。 ### 数据压缩 **程序中的指令数量通常非常庞大,如果在行号表中为每条指令都单独分配一行来存储映射关系,将会导致行号表的体积急剧膨胀。** 为了有效压缩行号表的大小,DWARF采用了以下几种关键优化策略: - 对于每条源码语句对应的多条机器指令,**只记录第一条指令的映射关系**,因为这已足够确定源码位置; - 将行号表数据转换为更紧凑的**字节码指令序列**形式。这种方式可以: - 省略相邻指令间相同的列值,避免冗余存储 - 对行号、列号等使用增量编码,即只存储与前一条记录的差值,这样通常只需要更少的位数 - 采用特殊的编码方案来处理常见模式 通过这些精心设计的压缩策略,行号表的存储效率得到了显著提升。DWARF最终将压缩后的行号表编码为一系列字节码指令,形成"**行号表程序**"。 调试器在使用时,会通过一个专门设计的**有穷状态机**来解释执行这些字节码指令。随着指令的逐条执行,完整的行号表就被逐步还原出来。这种设计既保证了数据的紧凑存储,又确保了运行时的高效访问。 ### 详细设计 #### 常用术语 在介绍行号表之前,我们先来了解几个重要的术语: - 状态机(State Machine):一个虚拟的执行器,用于解释执行字节码指令序列。行号表被编码为字节码指令,状态机通过执行这些指令来重建完整的行号表矩阵。 - 行号程序(Line Number Program):由一系列字节码指令组成的序列,这些指令编码了编译单元的行号表信息。状态机通过执行这些指令来还原行号表的内容。 - 基本块(Basic Block):一段连续的指令序列,具有以下特点: - 只有第一条指令可以作为跳转目标 - 只有最后一条指令可以进行控制转移 - 过程调用会导致基本块的结束 基本块是从控制流角度定义的概念,强调指令执行的连续性和跳转特性。基本块不一定对应着特定的源代码结构。 - 序列(Sequence):一段连续的目标机器指令集合。需要注意的是,一个编译单元可能会生成多个不连续的指令序列,因此不能假设编译单元中的所有指令都是连续存储的。序列是从内存布局角度定义的概念,强调指令在内存中的存储位置。 一个基本块必定是一个连续的指令序列,但一个连续的指令序列不一定构成一个基本块(不一定满足基本块的条件)。基本块更强调控制流的特性,而序列更关注指令的物理存储特性。 > ps: 不需要刻意去理解、区分??? #### 状态机寄存器 行号表信息状态机包含以下寄存器: - address: 程序计数器(PC)值,存储编译器生成的机器指令地址 - op_index: 无符号整数,表示操作的索引值。address和op_index组合构成操作指针,可引用指令序列中的任意操作 - file、line、column: 源代码位置的三元组,包含文件名、行号和列号 - is_stmt: 布尔值,标识当前指令是否为建议的断点位置(如语句的第一条指令) - basic_block: 布尔值,标识当前指令是否为词法块的起始位置 - end_sequence: 布尔值,标识当前地址是否为指令序列结束后的第一个字节。当end_sequence为true时,同一行的其他信息无意义 - prologue_end: 布尔值,标识函数序言中应该暂停执行的位置,当前位置是否适合设置函数入口断点 - epilogue_begin: 布尔值,标识函数结尾中应该暂停执行的位置,当前位置是否适合设置函数退出前断点 - isa: 无符号整数,标识当前指令所属的指令集架构 - discriminator: 无符号整数,由编译器分配,用于区分同一源码位置的多个代码块。若源码位置只对应单个块,则值为0 行号表程序开始执行时,状态机寄存器的初始状态如下: regs_initial_states #### 字节码指令 行号程序中的字节码指令分为以下三类: 1. 特殊操作码(Special Opcodes) - 由单个ubyte(无符号字节)表示操作码 - 不包含任何操作数 - 构成了行号表程序中的绝大多数指令 - 设计紧凑,执行效率高 2. 标准操作码(Standard Opcodes) - 以一个ubyte表示基本操作码 - 后面可跟随0个或多个LEB128编码的操作数 - 操作码本身决定了操作数的数量和含义 - 行号表程序头部会显式指明每个标准操作码的操作数数量,便于解析 3. 扩展操作码(Extended Opcodes) - 采用多字节操作码设计 - 第一个字节固定为0,用于标识扩展操作码 - 随后是LEB128编码的长度值,表示指令的总字节数(不含标识字节) - 最后是指令数据,其中首字节为ubyte类型的扩展操作码 - 支持更复杂的指令编码,具有良好的扩展性 #### 行号程序头 行号信息的最佳编码方式在一定程度上取决于目标机器的体系结构。行号程序头包含了调试器解码和执行行号程序指令时所需的关键信息。 每个编译单元的行号程序都以一个header开头,其包含以下字段: - unit_length(initial length):该编译单元的行号信息总字节数(不包含当前字段本身) - version(uhalf):版本号,这是行号信息特有的版本号,与DWARF版本号相互独立 - header_length:从该字段结束到行号程序第一个字节的偏移量。在32位DWARF中为4字节无符号整数,在64位DWARF中为8字节无符号整数 - minimum_instruction_length(ubyte):目标机器指令的最小字节长度。在修改address和op_index寄存器时,与maximum_operations_per_instruction一起参与计算 - maximum_operations_per_instruction(ubyte):单条指令可编码的最大操作数。在修改address和op_index寄存器时,与minimum_instruction_length一起参与计算 - default_is_stmt(ubyte):状态机寄存器is_stmt的初始值。对于源码语句对应的多条机器指令,至少要有一条指令的is_stmt为true,作为推荐的断点位置 - line_base(sbyte):用于特殊操作码的计算,详见下文 - line_range(sbyte):用于特殊操作码的计算,详见下文 - opcode_base(ubyte):第一个特殊操作码的值,通常比最大标准操作码值大1。如果该值小于最大标准操作码值,则大于opcode_base的标准操作码在当前编译单元中将被视为特殊操作码;如果该值大于最大标准操作码值,则中间的空隙可用于第三方扩展 - standard_opcode_lengths(ubyte数组):每个标准操作码对应的LEB128编码操作数的数量 - include_directories(路径名序列):编译单元包含的其他文件的搜索路径列表 - file_names(文件条目序列):与当前行号表相关的所有源文件名,包括主源文件和被包含文件 #### 行号表程序 行号程序的主要目标是构建一个矩阵,用于表示编译单元中生成的目标机器指令序列。在每个指令序列中,地址(操作指针)通常只会递增(但由于流水线调度或其他优化,行号可能会减少)。 行号程序由三类操作码组成:特殊操作码、标准操作码和扩展操作码。这里我们重点介绍特殊操作码的工作原理。如果您想了解标准操作码或扩展操作码的详细信息,请参考DWARF v4标准的6.2.5.2和6.2.5.3章节。 每个特殊操作码(以单个ubyte表示)执行时,会对状态机产生以下七个影响: 1. 给行寄存器(line)增加一个有符号数值 2. 通过增加address和op_index寄存器的值来更新操作指针 3. 根据当前状态机寄存器的值在矩阵中添加新的一行 4. 将basic_block寄存器置为"false" 5. 将prologue_end寄存器置为"false" 6. 将epilogue_begin寄存器置为"false" 7. 将discriminator寄存器置为0 所有特殊操作码都执行这七个相同的操作,它们的区别仅在于对line、address和op_index寄存器的增量值不同。 特殊操作码的值是根据需要添加到line、address和op_index寄存器的具体数值来选择的。行号增量的最大值由行号程序头中的line_base和line_range字段决定,计算公式为:line_base + line_range - 1。如果所需的行号增量超过了这个最大值,就必须改用标准操作码。operation advance表示操作指针前进时要跳过的操作数数量。 **计算特殊操作码公式如下**: ``` opcode = (desired line increment - line_base) + (line_range * operation advance) + opcode_base ``` 如果结果操作码大于255,则必须改用标准操作码。 当*maximum_operations_per_instruction*为1时,*operation advance*就是地址增量除以*minimum_instruction_length*。 **要解码特殊操作码公式如下**,要从操作码本身中减去opcode_base以提供调整后的操作码。*operation advance*是调整后的操作码除以*line_range*的结果。new address和 new op_index值由下式给出: ``` adjusted opcode = opcode – opcode_base operation advance = adjusted opcode / line_range new address = address + minimum_instruction_length * ((op_index + operation advance)/maximum_operations_per_instruction) new op_index = (op_index + operation advance) % maximum_operations_per_instruction ``` 当*maximum_operations_per_instruction*字段为1时,*op_index*始终为0,这些计算将简化为DWARF版本v3中为地址提供的计算。 line increment的数值是line_base加上以调整后操作码除以line_range的模的和。 就是: ``` line increment = line_base + (adjusted opcode % line_range) ``` 例如,当**假设opcode_base为13,line_base为-3,line_range为12,minimum_instruction_length为1,maximum_operations_per_instruction为1** ,下表中列出了当前假设下,当源码行相差[-3,8]范围内时、指令地址相差[0,20]时计算得到的特殊操作码值。 advance_btw_pc_and_lineno ### 示例演示 #### 生成行号程序 Figure 60中给出了简单的源文件和Intel 8086处理器的最终机器代码,在此基础上让我们来模拟行号表生成过程。 generate_line_num_table 现在,让我们逐步构建"行号表程序"。 实际上,我们需要先将源代码编译为汇编代码,然后计算每个连续语句的指令地址和行号的增量,根据 "指令地址增量(operation advance)" 以及 "行号增量(line increment)" 来计算操作码,这些操作码构成一个sequence,属于行号程序的一个部分。 例如, `2: main()` and `4: printf`, 这两条语句各自第一条指令的地址的增量为 `0x23c-0x239=3`, 两条源语句的行号增量为 `4-2=2`. 然后我们可以通过函数 `Special(lineIncr,operationAdvance)` 来计算对应的特殊操作码,即 `Special(2, 3)`。 generate_line_num_table2 回想一下上面提及的特殊操作码的计算公式: ``` opcode = (desired line increment - line_base) + (line_range * operation advance) + opcode_base ``` 假设行号程序头包括以下内容(以下不需要的头字段未显示): line_num_table_header 然后代入上述计算公式,Special(2, 3)的计算如下: ``` opcode = (2 - 1) + (15 * 3) + 10 = 56 = 0x38 ``` 这样就计算得到了构建行号表从 `2: main()`到 `4: printf`对应的行所需要的特殊操作码0x38。然后逐一处理所有相邻的源语句: 1. 第2行才生成指令,所以需要一个 `DW_LNS_advance_pc 0x239`,对应bytes为0x2,0xb9,0x04; 2. 源码第0行~第2行,源码增加2行,PC增加0,使用 `SPECIAL(2,0) = (2-1) + (15*0) + 10 = 11 = 0xb`,对应bytes为0xb; 3. 源码第2行~第4行,源码增加2行,PC增加0x23c-0x239=3,使用 `SPECIAL(2,3) = (2-1) + (15*3) + 10 = 0x38`,对应bytes为0x38; 4. 源码第4行~第5行,源码增加1行,PC增肌0x244-0x23c=8,使用 `SPECIAL(1,8) = (1-1) + (15*8) + 10 = 0x82`,对应bytes为0x82; 5. 源码第5行~第6行,源码增加1行,PC增加0x24b-0x244=7,使用 `SPECIAL(1,7) = (1-1) + (15*7) + 10 = 0x73`,对应bytes为0x73; 6. 已经没有源码行了,最后结束指令地址是0x24d,比之前源码第6行处指令地址0x244多了2,使用 `DW_LNS_advance_pc 0x2`,对应bytes为0x2,0x2; 7. 此时已经到了指令的结束,使用 `DW_LNE_end_sequence` 结束,对应bytes为0x0,0x1,0x1; 最终,我们就得到了如下行号表程序,最终这个ByteStream,会被写入.debug_line section: generate_line_num_table3 #### 执行行号程序 构建完整的行号表,我们需要先从目标程序中读取DWARF数据,然后再读取出行号表程序,就可以用准备好的行号表状态机来执行: - 读取到行号表程序的header,获取某些设置字段值; - 遍历行号表程序中的字节码指令,逐一执行 - 解码出opcode、address、pc advance、line advance、op_index - 对行号表进行更新或者追加 - 最终构建出我们期望中的行号表矩阵 假如使用Go开发调试器的话,Go标准库在读取ELF文件中的DWARF数据时,已经自动完成了每个编译单元中的行号表数据的解析,dwarf.LineReader中读取出来的LineEntries已经是解码、执行字节码指令之后得到的最终的行号表矩阵中的Rows相关的数据了。我们直接拿来进行查询即可。 [hitzhangjie/dwarfviewer](https://github.com/hitzhangjie/dwarfviewer) 就是在Go 标准库基础上实现了行号表信息的查看逻辑: dwarfviewer linetable view #### 查询行号程序 查询的情景有两种:根据源码位置查询PC,根据PC查询源码位置,我们简单说下查询的逻辑。 1)根据源码位置查询PC: - 我们知道了源码位置的三元组文件名、行号、列号,通过文件名我们可以知道对应的编译单元信息 - 找到编译单元对应的DIE,找到其行号表 - 查表 `entry.file==$sourcefile && entry.line==$lineno`,找到对应记录行的PC - 结束 2)根据PC查找源码位置: - 遍历所有的类型为编译单元的DIEs,查询[lowpc,highpc]包含了该PC的DIE,确定所属的编译单元DIE - 从该DIE中找到其行号表 - 查表 `entry.PC<=$PC && nextEntry.PC>$PC`,找到对应记录行,得到其file, line, col信息 - 结束 ### 本节小结 行号表是DWARF调试信息中的核心组件,它通过建立源代码位置与机器指令地址之间的映射关系,为源码级调试提供了基础支持。本文从行号表的功能、存储结构、数据编码、详细设计到实际应用,全面介绍了行号表的工作原理。通过精心设计的压缩策略和状态机机制,行号表既保证了数据的紧凑存储,又确保了调试器能够高效地访问这些信息,使得开发者能够更方便地进行程序调试和问题定位。 ps:由于篇幅原因,本文也跳过了一些DWARF v4行号表细节,感兴趣想进一步精进的读者可以自行了解,学习时也可以使用 [hitzhangjie/dwarfviewer](https://github.com/hitzhangjie/dwarfviewer) 来查看编译单元的行号表程序。 ### 参考资料 * DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf * Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf * dwarfviewer, https://github.com/hitzhangjie/dwarfviewer ================================================ FILE: book/8-dwarf/5-other/3-callframe-info.md ================================================ ## 调用帧信息表(Call Frame Information) ### 功能介绍 DWARF调试信息中的**调用帧信息表(CFI, Call Frame Information)**是一个重要组成部分,它为调试器提供了函数调用相关的关键信息。它记录了随着函数调用、函数执行期间PC变化时寄存器的值如何被修改的过程。有了这个表,我们就可以知道当前函数的基地址(CFA)、函数的参数、返回地址,并进而找到Caller的栈帧,因为还有各个寄存器的unwinding rules,我们也可以虚拟展开调用栈到指定的某个函数栈帧。 ### 调用栈展开 #### CFI栈展开的过程 CFI,我们使用"调用帧信息表"这个翻译,而不是"调用栈信息表"这个翻译。因为CFI记录的是如何从当前函数的帧展开到特定caller的帧时的数据,展开时只要找到对应的函数的FDE,然后执行这个FDE中的字节码指令即可,而不是逐一对callers进行处理一直到达指定caller这种回溯的方式。 > ps: 尽管我们可以做到展开整个调用栈,但是概念理解上扫清误区还是很重要。实际上DWARF的设计上,你可以展开任意函数的帧,只要这个函数的帧真的存在。通常我们查看caller的帧是因为caller->当前被调函数都是在特定tracee上执行,tracee停下来了我们方便观察它的状态。而即使我们直到某个线程执行了函数创建了对应的帧,但是由于调试器没有跟踪这个线程,这个时候去尝试展开它也是没有意义的。 举个例子,在CFI表中,每条机器指令基本对应一行记录:第一列是指令地址,其他列包含了执行该指令前各个寄存器的unwind操作。假定程序从fn1->fn2-> ... -> fnN,对应的PC从addr1一直执行到现在addrN。当我们希望虚拟展开fn1的栈时,该怎么操作呢?我们需要先找到fn1对应的FDE,然后使用CIE=FDE.cie_pointer,执行CIE的初始化指令以及FDE中的指令,直到达到fn1中的目标指令地址。此时,对应的寄存器状态就会被正确地虚拟恢复到fn1执行到该指令时的状态。 OK,调试器确实需要能够查看、修改调用栈上的任意活动记录(subroutine activation)的状态,我们说栈帧(帧)、活动记录,这不是一样的东西,前者强调的是帧的组织,后者强调的是函数调用时的一个新的调用记录,函数调用发生时,一个activation中至少包含: - 被调函数中的1个有效指令地址,该地址处要么是调试器获得控制权时程序停止的位置(例如断点),要么是调用另一个callee的位置,或者被异步事件(例如信号)中断的位置; - 栈上分配的内存区域"调用帧"(call frame)或者"函数的栈帧"。该函数对应的帧的起始地址,被称为"**Canonical Frame Address (规范帧地址,CFA)**",其实就是该函数的帧基址。 - 函数执行到特定指令位置时使用过的一组寄存器; > ps: 例如函数a调用函数b,会先入栈b函数的参数(返回值go1.17前也是在栈上开辟,1.17后通过寄存器传递),再入栈b函数的返回地址(即rip值),再入栈rbp,...对于b函数栈帧而言,CFA就是其栈帧的基地址,其实也就是入栈rip之前的caller的rsp值。 为了能够查看或修改不在栈帧顶部的某个函数帧的,调试器必须能"虚拟地展开(virtually unwind)"整个调用栈直到目标函数帧被展开。展开的过程,从当前函数帧、指令地址开始,查CFI信息表,找到对应的记录,然后逆序执行对应的寄存器unwinding rules,一直执行直到展开目的栈帧为止。 联想下gdb调试的过程,通过bt可以看到调用帧,然后通过frame N来选择指定的帧。这个时候就是一个虚拟地展开调用帧的过程,Nth帧中的寄存器状态被计算了出来存在某处,比如rsp、rbp被重新算了存在某处,并用于后续print等操作时计算变量地址,这样我们就可以看到Nth帧的函数参数、局部变量、寄存器值。为什么说是虚拟地展开?就是说,这里寄存器unwinding rules只用于重新计算当时寄存器的值,但是并不会设置回寄存器去,并没有修改进程的状态,只是看上去return到对应的caller的栈帧中去了……所以才说是virtually unwind。 通常,在函数调用时会指定一组寄存器并将其状态进行保存。如果被调函数要使用一个寄存器,它就要在函数入口处保存该寄存器值到栈帧中,并在函数退出时将其恢复。 - 在调用帧上调整帧大小(用于分局部变量)并保存寄存器状态的这部分代码,称函数序言(prologue); - 执行寄存器状态恢复并销毁调用帧的这部分代码,称为函数后记(epilogue)。 通常,序言代码实际上在函数的开头,而后记代码在函数的结尾。 #### 架构无关编码方式 展开堆栈操作,需要知道寄存器的保存位置以及如何计算调用方的CFA和代码位置。在考虑体系结构无关的信息编码方式时,有些特殊事项需要考虑: - 子例程(函数)序言和后记代码,并不总是位于子例程的开头和结尾这两个不同的块中。通常子例程后记部分代码会被复制到每次return返回操作的地方。有时,编译器也会将寄存器保存、取消保存操作分割开,并将它们移到子例程代码需要用到它们的位置; - 编译器会使用不同的方式来管理调用帧,有时是通过一个栈指针,有时可能不是; - 随着子例程序言和后记部分代码的执行,计算CFA的算法也会发生变化(根据定义,CFA值不变); - 一些子例程调用是没有调用帧的(如可能通过"尾递归"优化掉了); > 关于编译器对尾递归的优化,可以参考博文: [tail recursion call optimization](http://www.ruanyifeng.com/blog/2015/04/tail-call.html),当前go编译器还不支持尾递归优化,gcc是支持的。 > - 有时将一个寄存器的值保存在另一个寄存器中,但是后者可能按照惯例是不需要在子例程序言中存储的; - 某些体系结构具有特殊的指令,这些指令可以在一条指令中执行部分或全部的寄存器管理,而在堆栈上留下一些特殊信息来指示寄存器该如何保存; - 一些体系结构处理返回地址值比较特殊。 例如,在有的体系结构中,调用指令可确保调用地址低两位为零,而返回指令将忽略这些位。 这留下了两个存储位,可供其他用途使用,必须对其进行特殊处理。 ### CFI详细设计 #### CFI表结构设计 DWARF定义了独立于体系结构的基本要素来支持"虚拟展开(virtually unwind)"调用帧,这些基础要素能够记录子例程调用期间如何保存和恢复寄存器的状态。对于某些特定机器,其可能拥有些ABI委员会、硬件供应商或编译器生产商定义的体系结构特定的信息,需要借助这些信息对DWARF基本要素进行补充。 CFI描述的表结构,如下图所示: image-20191229130341692 - 第1列,指令地址。表示程序中指令的地址(在共享库文件,指令地址是一个相对于起始地址的偏移量); - 第2列,CFA(Canonical Frame Address),规范帧地址,简单说其实就是Callee的栈帧的基地址。CFA列的地址计算规则,可以是结合寄存器、偏移量计算,也可以由DWARF表达式计算。 - 其他列,各寄存器对应的虚拟展开规则(virtual unwinding rules);这里的寄存器规则,包括: - undefined,该规则表示对应寄存器在前一个栈帧中没有可恢复的值。通常是,在调用callee的时候没有对相关寄存器的状态进行保存; - same value,该规则表示对应寄存器的值与前一个栈帧中寄存器的值相同。通常是,在调用callee的时候对相关寄存器的状态进行了保存,但是并没有进行修改; - offset(N),该规则表示对应寄存器的值被保存在CFA+N对应的地址处,CFA就是当前的CFA值,N是有符号偏移量; - val_offset(N),该规则表示对应寄存器的值就是CFA+N的值,CFA就是当前的CFA值,N是有符号偏移量; - register(R),该规则表示对应寄存器的值,被保存在另一个寄存器R中; - expression(E),该规则表示对应寄存器的值,保存在DWARF表达式E对应的内存地址中; - val_expression(E),该规则表示对应寄存器的值,就是DWARF表达式E的值; - architectural,该规则不是当前规范内的定义,它由augmenter定义; 上述CFI信息表,如果我们每条指令都真的存一条记录,则该表空间将会非常大!为了高效存储这些信息,CFI采用了与行号表类似的压缩策略:将信息编码为字节码指令序列,完整的CFI表体积虽然庞大,但由于相邻指令间的状态变化通常很小,我们仍然可以基于差异、增量进行存储,这种编码方式可以让CFI保持相当紧凑的存储格式。还原该CFI表矩阵的时候,这些指令由专门的CFI状态机解释执行即可。 上述CFI信息表被编码在 "**.debug_frame**" section 中。 .debug_frame节中的条目相对于该节的开头按地址大小的倍数对齐,并以两种形式出现: - 公共信息条目(Common Information Entry, CIE); - 帧描述条目(Frame Descriptor Entry, FDE); > ps:如果函数的代码段地址范围不是连续的,那可能存在多个CIEs和FDEs。 构建CFI表结构,离不开CIE、FDE,因为构建CFI表结构的字节码程序存储在它们之中,我们需要先介绍下CIE、FDE的内容之后再来介绍如何生成CFI表。 #### 公共信息条目(CIE) 每个编译单元都有一个CIE,每个公共信息条目(CIE,Common Information Entry)的信息,可能会被很多帧描述条目FDE所共享。每个非空的.debug_frame section中至少包含一个CIE,每个CIE都包含如下字段: 1. length (初始长度),常量,指明了该CIE结构的大小(字节数量),不包含该字段本身。length字段所占字节数,加上length的值,必须是按照address size对齐; 2. CIE_id (4字节或8字节),常量,用于CIEs、FDEs; 3. version(ubyte),版本号,该值与CFI信息有关,与DWARF版本无关; 4. augmentation (UTF-8字符串),null结尾的UTF-8字符串,用于标志当前CIE和使用它的FDEs的扩展信息 5. address_size (ubyte),该CIE中以及使用该CIE的其他FDEs中,目标机器地址占用几个字节,如果该frame存在一个编译单元,其中的address size必须与这里的address size相同; 6. segment_size (ubyte),该CIE中以及使用该CIE的其他FDEs中,段选择器占用几个字节; 7. code_alignment_factor (unsigned LEB128),常量,指令地址偏移量 = operand * code_alignment_factor; 8. data_alignment_factor (signed LEB128),常量,偏移量 = operand * data_alignment_factor; 9. return_address_register (unsigned LEB128),常量,指示返回地址存储在哪里,可能是物理寄存器或内存 10. initial_instructions (array of ubyte),一系列rules,用于指示如何创建CFI信息表的初始设置; 在执行initial instructions之前,所有列的默认生成规则都是undefined,不过, ABI authoring body 或者 compilation system authoring body 也可以为某列或者所有列指定其他的默认规则; 11. padding (array of ubyte),字节填充,通过DW_CFA_nop指令填充结构体,使CIE结构体大小满足length要求,length值加字段字节数必须按照address size对齐; #### 帧描述条目(FDE) 每个函数都有一个FDE,一个帧描述条目(FDE,Frame Descriptor Entry)包含如下字段: 1. length (初始长度),常量,指明该函数对应header以及instruction流的字节数量,不包含该字段本身。length字段大小(字节数),加上length值,必须是address size(FDE引用的CIE中有定义)的整数倍,即按address size对齐; 2. CIE_pointer (4或8字节),常量,该FDE引用的CIE在.debug_frame的偏移量; 3. initial_location (段选择器,以及目标地址),该table entry对应第一个指令地址,如果segment_size(引用的CIE中定义)非0, initial_location前还需要加一个段选择器; 4. address_range (target address),该FDE描述的程序指令占用的字节数量; 5. instructions (array of ubyte),FDE中包含的指令序列,在后面进行描述; 6. padding (array of ubyte),字节填充,通过DW_CFA_nop指令填充结构体,使FDE结构体大小满足length字段要求; #### 字节码指令分类 调用帧指令(Call Frame Instructions),每条指令可以包含零个或多个操作数,某些操作数会被编码到操作码中。部分指令的操作数通过DWARF表达式编码。CIE中的初始化指令序列、FDE中的指令序列,执行这两部分指令用于创建CFI表结构。 这里的调用帧指令,包括如下这几类: - CFI表的行创建指令,用于创建表中的一行; - CFI表的CFA定义指令,用于定义当前这一行的CFA计算规则; - CFI表的寄存器规则指令,用于定义当前这一行中的其他寄存器的unwinding rules; - CFI表的行状态指令,寄存器状态保存入栈和获取的能力; - CFI表的行填充指令,填充nop,什么也不干; ##### CFI表行创建指令(Row Creation Instructions) 1. DW_CFA_set_locDW_CFA_set_loc指令采用代表目标地址的单个操作数。 所需的操作是使用指定的地址作为新位置来创建新的表行。新行中的所有其他值最初都与当前行相同。 新位置值始终大于当前位置值。 如果此FDE的CIE的segment_size字段不为零,还需要在在初始位置之前加上段选择器。 2. DW_CFA_advance_locDW_CFA_advance指令采用单个操作数(在操作码中编码),该操作数表示常数增量。 所需的操作是使用位置值创建一个新表行,该位置值是通过获取当前条目的位置值并加上delta * code_alignment_factor的值来计算的。 新行中的所有其他值最初都与当前行相同。 3. DW_CFA_advance_loc1DW_CFA_advance_loc1指令采用一个表示常量增量的单个ubyte操作数。 除了增量操作数的编码和大小外,该指令与DW_CFA_advance_loc相同。 4. DW_CFA_advance_loc2DW_CFA_advance_loc2指令采用单个uhalf操作数表示常数增量。 除了增量操作数的编码和大小外,该指令与DW_CFA_advance_loc相同。 5. DW_CFA_advance_loc4 DW_CFA_advance_loc4指令采用单个uword操作数来表示恒定增量。 除了增量操作数的编码和大小外,该指令与DW_CFA_advance_loc相同。 ##### CFI表CFA定义指令(CFA Definition Instructions) 1. DW_CFA_def_cfaDW_CFA_def_cfa指令有两个操作数,均为无符号LEB128编码,分别代表寄存器号和non-factored偏移量。该指令定义的CFA规则使用提供的寄存器和偏移量。 2. DW_CFA_def_cfa_sfDW_CFA_def_cfa_sf指令采用两个操作数:代表寄存器号的无符号LEB128值和有符号LEB128 factored偏移量。 该指令与DW_CFA_def_cfa相同,不同之处在于第二个操作数是有符号的因数(signed factored)。 结果偏移量为factored_offset * data_alignment_factor。 3. DW_CFA_def_cfa_registerDW_CFA_def_cfa_register指令采用表示寄存器编号的单个无符号LEB128操作数。 该指令定义当前的CFA规则以使用提供的寄存器(但保留旧的偏移量)。 仅当当前CFA规则定义为使用寄存器和偏移量时,此操作才有效。 4. DW_CFA_def_cfa_offsetDW_CFA_def_cfa_offset指令采用单个无符号LEB128操作数表示一个non-factored偏移量。 该指令定义当前的CFA规则使用提供的偏移量(但保留旧寄存器)。 仅当当前CFA规则定义为使用寄存器和偏移量时,此操作才有效。 5. DW_CFA_def_cfa_offset_sfDW_CFA_def_cfa_offset_sf指令采用带符号的LEB128操作数,表示factored偏移量。 该指令与DW_CFA_def_cfa_offset相同,除了该操作数是有符号的因数(signed factored)。 结果偏移量为factored_offset * data_alignment_factor。 仅当当前CFA规则定义为使用寄存器和偏移量时,此操作才有效。 6. DW_CFA_def_cfa_expression DW_CFA_def_cfa_expression指令采用单个操作数,该操作数编码为表示DWARF表达式的DW_FORM_exprloc值。 该指令通过表达式作为计算当前CFA的方式。 > ps: 有关可使用的DWARF表达式运算符的限制,请参见第DWARF v4 section 6.4.2。 ##### CFI表寄存器规则指令(Register Rule Instructions) 1. DW_CFA_undefinedDW_CFA_undefined指令采用单个无符号LEB128操作数来表示寄存器号。该指令指定寄存器unwind规则设置为"undefined"。 2. DW_CFA_same_valueDW_CFA_same_value指令采用单个无符号的LEB128操作数来表示寄存器号。 该指令将指定寄存器unwind规则设置为"same"。 3. DW_CFA_offsetDW_CFA_offset指令采用两个操作数:一个寄存器号(使用操作码编码)和一个无符号的LEB128常量(factored偏移量)。 该指令将指定寄存器号指示的寄存器unwind规则更改为offset(N)规则,其中N的值是分解后偏移量 * data_alignment_factor。 4. DW_CFA_offset_extendedDW_CFA_offset_extended指令采用两个无符号的LEB128操作数,它们表示寄存器号和factored偏移量。 该指令与DW_CFA_offset相同,不同之处在于寄存器操作数的编码和大小。 5. DW_CFA_offset_extended_sfDW_CFA_offset_extended_sf指令采用两个操作数:代表寄存器号的无符号LEB128值和有符号LEB128编码的factored偏移量。 该指令与DW_CFA_offset_extended相同,不同之处在于第二个操作数是有符号factored偏移量。 结果偏移量为factored_offset * data_alignment_factor。 6. DW_CFA_val_offsetDW_CFA_val_offset指令采用两个无符号的LEB128操作数,它们代表寄存器号和factored偏移量。 所需的操作是将寄存器编号指示的寄存器规则更改为val_offset(N)规则,其中N的值是factored_offset * data_alignment_factor。 7. DW_CFA_val_offset_sfDW_CFA_val_offset_sf指令采用两个操作数:代表寄存器号的无符号LEB128值和有符号LEB128因数偏移量。 该指令与DW_CFA_val_offset相同,不同之处在于第二个操作数是有符号factored偏移量。 结果偏移量为factored_offset * data_alignment_factor。 8. DW_CFA_registerDW_CFA_register指令采用两个无符号的LEB128操作数表示寄存器编号。 该指令将第一个寄存器的unwind规则设置为register(R),其中R是第二个寄存器。 9. DW_CFA_expressionDW_CFA_expression指令采用两个操作数:代表寄存器号的无符号LEB128值和代表DWARF表达式的DW_FORM_block值。 该指令将由寄存器号指示的寄存器的unwind规则更改为expression(E)规则,其中E是DWARF表达式。 > 执行DWARF表达式之前,要先将当前CFA的值入到运算用的栈中,最后DWARF表达式执行完成后栈顶就是结果。有关可使用的DWARF表达式运算符的限制,请参见DWARF v4 section 6.4.2。 > 10. DW_CFA_val_expressionDW_CFA_val_expression指令采用两个操作数:代表寄存器号的无符号LEB128值和代表DWARF表达式的DW_FORM_block值。 该指令将LEB128值指代的寄存器unwind规则修改为为val_expression(E)规则,其中E是DWARF表达式。 11. DW_CFA_restoreDW_CFA_restore指令采用单个操作数(用操作码编码),该操作数表示寄存器号。该指令将指定寄存器unwind规则更改为CIE中initial_instructions为其分配的规则。 12. DW_CFA_restore_extended DW_CFA_restore_extended指令采用单个无符号的LEB128操作数来表示寄存器号。 该指令与DW_CFA_restore相同,不同之处在于寄存器操作数的编码和大小。 ##### CFI表行状态指令(Row State Instructions) 接下来的两条指令提供了将寄存器状态保存入栈和获取的能力。 比如,对于编译器需要将函数epilogue代码移入函数体中return的地方的时候,它们就很有用。 1. DW_CFA_remember_stateDW_CFA_remember_state指令不接受任何操作数,它将每个寄存器的规则集压入隐式堆栈。 2. DW_CFA_restore_state DW_CFA_restore_state指令不接受任何操作数,它将规则集从隐式堆栈中弹出,并将其放置在当前行中。 ##### CFI表字节填充指令(Padding Instruction) 1. DW_CFA_nop DW_CFA_nop指令没有操作数,也没有必需的操作。 它用作填充字节以使CIE或FDE大小合适。 ### CFI两种用法 #### 调用帧指令使用(Call Frame Instruction Usage) 调用帧信息(CFI)的主要用途是在程序执行过程中进行栈回溯(stack unwinding),以重建函数调用链。为了实现这一目标,我们需要能够确定任意执行点的寄存器状态。下面介绍如何使用CFI指令来获取这些信息。 为了**确定给定位置(L1)的虚拟展开规则集(virtual unwind rule set)**,首先需要在FDE headers中搜索包含该位置的FDE。这可以通过比较FDE headers中的initial_location和address_range值来完成。 一旦找到相应的FDE,就可以按照以下步骤确定该位置的unwind rule set: 1. 通过读取FDE关联的CIE的initial_instructions字段来初始化寄存器集合; 2. 读取并处理FDE的指令序列,直到遇到地址大于L1的指令DW_CFA_advance_loc,DW_CFA_set_loc,或遇到指令流的末尾; 3. 如果遇到DW_CFA_advance_loc或DW_CFA_set_loc指令,则计算一个新的位置值(L2)。 如果L1 >= L2,则处理该指令并返回步骤2继续执行; 4. 指令流的末尾可被视为DW_CFA_set_loc(initial_location+address_range)指令。请注意,如果执行到指令流的末尾后,如果L2 ps:所以,Calling Address计算得到的是Callee的函数返回地址-1。将这个地址值减去1(比如Call是多字节指令),虽然得到的并不是函数调用之前的精确的指令地址,但是至少可以帮助我们确定函数调用发生时的源码位置、栈帧,就可以查看当时的源码位置、上下文信息,通常就足够了。 > > 我们通过 `gdb> bt`查看当前所有的栈帧,并通过 `frame`命令选择栈帧时并不在乎精确的调用地址是什么,只要能够还原对应的调用时的源码位置、栈帧上下文就足够了。 > ps: 调用地址与返回地址不在同一个上下文中,这点可能有点费解,但是如果你看过Linux内核启动代码的话,你就非常容易理解这点。Linux内核head.s为了填充BIOS 16位操作模式向Linux 32/64位模式之间的转换,需要做很多工作,如支持32位/64位寻址、重建32位/64位中断向量表、重建GDT等等,最后才是调用Linux内核的main函数,因为这里的main函数永远不会返回,head.s里面其实是通过ret main地址,来模拟的call main。表现就是函数调用地址、返回地址根本不在同一个上下文中。 > > 这里只是举个例子方便大家理解这个点,但也不是说该例子中的情景就完全覆盖了、等同于上面的这个点。 ### 示例演示 #### 机器信息 下面的示例,假定是一个RISC机器,Motorola 88000。 - 内存按字节进行寻址; - 指令都是4字节定长指令,并且都是按word对齐的; - 指令操作数,一般这样组织:`, , ` - load、store指令的内存地址,通过源操作数寄存器source.reg中的值和常量const相加进行计算; - 有8个4字节寄存器: R0:总是0; R1:在进行函数调用时,保存返回地址; R2-R3:临时寄存器,在进行函数调用时,不用保存原来的值; R4-R6:进行函数调用时,需要保存原来的值; R7:保存栈指针值; - 栈增长方向是从高地址向低地址方向增长; - 架构ABI委员会指定栈指针 `(R7)`与CFA相同; #### foo对应机器指令 下面是函数foo对应的两个机器指令片段,分别是函数序言(prologue)以及函数后记(epilogue)部分,里面除了使用stack pointer以外,还使用了frame pointer。第一列是指令地址,``表示stack frame(栈帧)的大小(按字节算),这个示例中是12字节。 image-20191229171656174 #### foo对应CFI表 上图63对应的CFI信息表如下图64所示,.debug_frame section中对应的代码片段如图65所示。 我们在图64 CFI信息表中使用了下面这些注解符号,请先了解其表示的含义。 1. R8 存储返回地址 2. s = same_value unwind规则 3. u = undefined unwind规则 4. rN = register(N) unwind规则 5. cN = offset(N) unwind规则 6. a = architectural unwind规则 image-20191229172236863 #### CIE中initial instruction指导创建CFI中第一行 CFI信息表第一行,是由当前被调函数foo对应的FDE.CIE_Pointer所引用的CIE中的initial instructions来创建的,因此想了解第一行为什么是 `foo [R7]+0 s u u u s s s a r1`,就需要结合CIE来看,下图65中给出了CIE的说明。 CIE中规定R8是返回地址寄存器,该机型Motorola 88000规定函数调用时R1保存返回地址,故R8的值实际上在R1中。明确了这点后我们看下CIE中的initial instructions部分是如何指导创建CFI信息表第一行的。 在foo第一条指令执行之前,PC值为foo符号对应的内存地址: - DW_CFA_def_cfa(7,0) 规定CFA=[R7]+0,表示foo的标准帧地址CFA就是调用方的栈指针值,即R7的值(此时 `R7=R7-`,此时还没有执行,还没有为foo分配栈帧),得到了 `foo [R7]+0`; > ps:一般是函数调用指令,如call,将返回地址(PC值)push到调用栈作为后续函数执行完成后的返回地址。 > - DW_CFA_same_value(0) 规定R0寄存器总是0,使用same unwind规则,也可以理解,得到了 `foo [R7]+0 s`; - DW_CFA_undefined(1)/(2)/(3) 规定R1/R2/R3寄存器使用undefined unwind规则,R2、R3因为是无需保存的临时寄存器,所以使用undefined规则无可厚非,而R1实际上是保存返回地址的,这个程序中实际上没有将R1用作其他木目的,所以也是undefined。这样就得到了 `foo [R7]+0 s u u u`; > 如果prologue后面代码有用到R1的话,epilogue一定会有其他unwind规则来恢复,但是没有,说明根本就没有使用到R1; > - DW_CFA_same_value(4)/(5)/(6) 规定R4/R5/R6寄存器使用same unwind规则,R4、R5、R6都是需要保存状态的寄存器,所以这里使用same unwind规则。这样就得到了 `foo [R7]+0 s u u u s s s`; - R7本来是保存栈指针值,它也比较特殊,架构ABI委员会规定它和CFA相同,这里用architecutural unwind规则,得到了 `foo [R7]+0 s u u u s s s a`; - DW_CFA_register(8,1) 规定寄存器R8存储在寄存器R1中,因为R8表示返回地址,而R1中记录着返回地址,所以使用register(8,1)。这样就得到了 `foo [R7]+0 s u u u s s s a r1`; - 接下来是padding指令,填充CIE结构体大小使其满足CIE.length要求,这与构建CFI信息表无关,忽略。 当上述几条指令执行后,CFI信息表中的第一行就构建完成了:`foo [R7]+0 s u u u s s s a r1`。 image-20191229172436047 #### FDE中指令序列指导创建CFI表中第2行、第3行、第n行 然后结合foo机器指令代码片段,我们再来看下其中prologue、epilogue部分,这两部分对应的机器指令对应的FDE中的指令序列应该长什么样子,以及这些指令作用到CFI表又是什么样的效果。 回顾下foo prologue、epilogue片段对应的机器指令: image-20191229171656174 下图66中展示了图63机器指令对应的FDE中的指令序列,图66中使用了如下注解: 1. `` = 栈帧大小 2. `` = 代码对齐因子,code alignment factor 3. `` = 数据对齐因子,data alignment factor IMG_0050 大家一定有疑问,FDE中的指令序列是如何生成的?图63中的每条机器指令的地址是不同的,其影响的寄存器也是不同的,实际上就是根据每条机器指令的具体动作,来生成对应的CFI表构建指令而已,比如CFI表row create rule,以及受影响的寄存器的unwind rule,仅此而已。下面结合图63中机器指令说明下FDE中的指令序列是如何构建的,以及反映到CFI信息表中又是什么样的。看完这里,大家就会对CFI表的构建了然于胸了! CFI信息表第一行:`foo [R7]+0 s u u u s s s a r1`,它表示PC=foo处地址时,如何计算CFA以及如何恢复各个寄存器。下面解释代码中各条指令操作,如何转换成对应的CFI row rule set。 - `foo sub R7, R7, ` R7存储的是栈指针值,指令执行后 `R7=R7-`,相当于分配了一个 ``大小的栈帧给foo使用。因为CFI之前用R7、偏移量来作为计算CFA的规则,此处R7的值减少了 ``,所以需要对CFA规则进行调整。首先需要一条row create rule,然后再来一条CFA调整规则,也就是下图66中的DW_CFA_advance_loc(1)以及DW_CFA_def_cfa_offset(12),因为不涉及其他寄存器调整,这两个unwind rule就够了。 这里的DW_CFA_advance_loc(1)表示指令地址前进code_alignment_factor * 1 = 4 * 1 = 4,表示该条机器指令执行后PC=foo+4。DW_CFA_def_cfa_offset(12)表示CFA计算规则中寄存器还是R7,但是偏移量由0变成+12。 这样CFI表第二行就是 `foo+4 [R7]+fs s u u u s s s a r1`,其中fs=12。 > 因此CFI信息表中各行 `addr : unwind rule set`表示的是PC=addr(该地址处指令待执行)时的unwind rule set。 > - `foo+4 store R1, R7, (-4)` R1里面存的是返回地址,`R7+-4`是foo栈帧最高的4个字节,该指令意图将foo函数调用的返回地址(R1值)存储到该位置。因为CFI信息表中R8用来存储返回地址,这里需要调整下R8的unwind rule。需要两个操作,首先是需要一条row create rule,接下来再来一条R8的unwind rule。 DW_CFA_advance_loc(1)、DW_CFA_offset(8,1),DW_CFA_advance_loc(1)就是将指令地址偏移量增加 `code_alignment_factor * 1 = 4 * 1`,也就是addr = foo+8。DW_CFA_offset(8,1)表示R8寄存器存储在 `当前CFA+偏移量data_alignment_factor * 1 = CFA + (-4) * 1 = CFA-4`的地方,也就是unwind rule变成了c-4。 这样我们就得到了CFI表第二行 `foo+8 [R7]+fs s u u u s s s a c-4`。 > data_alignment_factor=-4,这个在CIE中有定义。 > - `foo+8 store R6, R7, (-8)` 这里是要存储R6寄存器的值到foo栈帧的第4~8字节的位置,影响的是R6寄存器的unwind规则,我们需要两个操作,一个是row create rule,一个是调整R6的unwind rule。 DW_CFA_advance_loc(1)、DW_CFA_offset(6,2),表示指令地址 `addr+=code_alignment_factor * 1`,即foo+12,并且R6的值存储在 `当前CFA+data_alignment_factor*2 = CFA+(-4)*2 = CFA-8`的位置,CFA-8表示的刚好是foo栈帧4~8节的位置。 这样我们就得到了CFI表第三行 `foo+12 [R7]+fs s u u u s s c-8 a c-4`。 - ... - 其他汇编指令对应的FDE中的操作以及解释,就不一一列举了,大体上就是按照上面这个方式来。 到这里,我们应该对以下过程"**源码->机器指令->CIE+FDE中指令序列的生成方式->CIE、FDE中指令对CFI表构建的过程**"都了解清楚了。而关于 `CFI表的运用`,如给定一个指令地址L1,进一步确定其对应的CFA,或者返回地址,这些更简单,前面也都讲过了。 至此,关于调用帧信息表的介绍可以宣布告一段落了。 ### 本节小结 本文详细介绍了DWARF调试信息中的调用帧信息表CFI的相关内容,包括其基本概念、功能作用、表结构设计(CIE和FDE)以及字节码指令系统。通过Motorola 88000架构的具体示例,展示了从源代码到机器指令,再到CFI表构建的完整过程,说明了如何通过CIE和FDE中的指令序列来构建CFI表,以及如何利用这些信息进行栈回溯和调用栈展开。这些知识对于理解调试器的工作原理和程序执行过程具有重要意义。 ================================================ FILE: book/8-dwarf/5-other/4-macro-info.md ================================================ ## 宏信息 大多数调试器很难显示和调试具有宏的代码。 用户看到带有宏的原始源文件,而代码则对应于宏展开后的内容。 DWARF调试信息中包含了程序中定义的宏的描述。 这是非常基本的信息,但是调试器可以使用它来显示宏的值或将宏翻译成相应的源语言。 c\c++等需要支持宏的编程语言中需要用到这里的信息.debug_macro,go语言中用不到,这里就不展开了。 ================================================ FILE: book/8-dwarf/5-other/5-varlen-data.md ================================================ ## 可变长数据 在整个DWARF中有大量信息使用整数值来表示,从数据段偏移量,到数组或结构体的大小,等等。由于大多数整数值是小整数,用几位就可以表示,因此这意味着数据主要由零组成,对应的bits相当于被浪费了。 DWARF定义了一个可变长度的整数编码方案,称为Little Endian Base 128(LEB128),它能够压缩实际占用的字节数,减小编码后的数据量。LEB128有两种变体: - ULEB128: 用于编码无符号整数 - SLEB128: 用于编码有符号整数 ### ULEB128编码方案 UELB128编码算法: ``` MSB ------------------ LSB 10011000011101100101 In raw binary 010011000011101100101 Padded to a multiple of 7 bits 0100110 0001110 1100101 Split into 7-bit groups 00100110 10001110 11100101 Add high 1 bits on all but last (most significant) group to form bytes 0x26 0x8E 0xE5 In hexadecimal → 0xE5 0x8E 0x26 Output stream (LSB to MSB) ``` 用咱家乡话总结下就是: 1. 将数字转换为二进制数表示,这些字节按小端序排列(即最低有效字节在前) 2. 将整数按7位一组进行分割 3. 每组7位存储在一个字节中,该字节的最高位(第8位)用作标志位: - 如果后面还有更多组,则该位设为1 - 如果是最后一组,则该位设为0 例如,数字Uint64 624485的ULEB128编码为: 624485 = 0x98765 十六进制数,一个数位由4位二进制表示,转换为二进制表示为: ``` 1001 1000 0111 0110 0101 ``` 考虑到7位一组进行分割,先填充为7bits的整数倍: ``` 0 1001 1000 0111 0110 0101 ``` 然后7位一组进行分割,注意是小端字节序,所以从右边开始分割: ``` 0 1001 10 / 00 0111 0 / 110 0101 ``` - 第1组: 110 0101,因为后续还有,所以第8位标记为1,最终为 1110 0101 = 0xe5 - 第2组: 00 0111 0, 000 1110,第8位标记为1,最终为 1000 1110 = 0x8e - 第3组: 0 1001 10, 010 0110,第8位标记wi0,最终为 0010 0110 = 0x26 编码后的字节序列为: []byte{0xe5 0x8e 0x26},最终只占用了3个字节,而如果用原始数据类型uint64则要用8个字节。 ### SLEB128编码方案 SLEB128的编码规则类似,只是在处理负数时需要考虑符号位扩展,处理正数时没有区别。 SLEB128负数编码算法: ``` MSB ------------------ LSB 11110001001000000 Binary encoding of 123456 000011110001001000000 As a 21-bit number 111100001110110111111 Negating all bits (ones' complement) 111100001110111000000 Adding one (two's complement) 1111000 0111011 1000000 Split into 7-bit groups 01111000 10111011 11000000 Add high 1 bits on all but last (most significant) group to form bytes 0x78 0xBB 0xC0 In hexadecimal ``` 用咱家乡话说就是: 1. 如果是负数,先转换为二进制补码表示 2. 然后7bits一组进行分组,依然按照小端来 3. 除了最后一组,每个分组的第8位都标记为1 4. 得到最终结果 ### 本节小结 本文介绍了ULEB128、SLEB128这种对小整数编码存储友好的编码方案,可以节省存储空间占用。对于正数,ULEB128、SLEB128编码结果是相同的,只是对于负数时,SLEB128要先转换为其补码再进行编码。通过这种编码方式,DWARF能够有效压缩整数数据,减小调试信息的体积。在实际应用中,大多数整数值都可以用1-2个字节表示,相比固定4字节或8字节的存储方式节省了大量空间。 ================================================ FILE: book/8-dwarf/5-other/6-shrink-data.md ================================================ ## 压缩DWARF数据 与DWARF v1相比,DWARF新版本使用的编码方案大大减少了调试信息的大小。但不幸的是,编译器生成的调试信息仍然很大,通常大于可执行代码和数据的存储占用。DWARF新版本提供了进一步减少调试数据大小的方法,比如使用zlib数据压缩。 下面是一个生产环境服务(编译后大约147MB),即使采用了DWARF v4并且开启了DWARF数据压缩之后 (Flags==C),编译完也有147MB。构建后文件尺寸大,是多方面原因,比如这里的Go程序使用静态链接,符号表信息,调试信息也都有保留。 ```bash root🦀 bin $ readelf -S grpc_admin_svr There are 36 section headers, starting at offset 0x238: Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align ... [24] .debug_abbrev PROGBITS 0000000000000000 0688d000 0000000000000135 0000000000000000 C 0 0 1 [25] .debug_line PROGBITS 0000000000000000 0688d135 00000000006538d5 0000000000000000 C 0 0 1 [26] .debug_frame PROGBITS 0000000000000000 06ee0a0a 000000000012ada4 0000000000000000 C 0 0 1 [27] .debug_gdb_script PROGBITS 0000000000000000 0700b7ae 0000000000000030 0000000000000000 0 0 1 [28] .debug_info PROGBITS 0000000000000000 0700b7de 0000000000ace1cb 0000000000000000 C 0 0 1 [29] .debug_loc PROGBITS 0000000000000000 07ad99a9 0000000000881add 0000000000000000 C 0 0 1 [30] .debug_ranges PROGBITS 0000000000000000 0835b486 00000000004836ee 0000000000000000 C 0 0 1 ... ``` 那我们去掉DWARF数据看看能节省多少存储占用,使用objcopy去掉所有的DWARF debug sections,然后查看文件大小,32MB!!! ```bash root🦀 bin $ objcopy --strip-debug grpc_admin_svr grpc_admin_svr.stripped root🦀 bin $ ll -h total 262M drwxr-xr-x 2 root root 4.0K May 18 11:34 ./ drwxr-xr-x 4 root root 4.0K Apr 28 16:05 ../ -rwxr-xr-x 1 root root 147M May 12 13:01 grpc_admin_svr -rwxr-xr-x 1 root root 115M May 18 11:34 grpc_admin_svr.stripped ``` 32MB还是有点大的,大多数编程语言是默认不生成调试信息的,Go语言是个例外。 golang/go issues也有讨论是否应该默认关闭DWARF数据生成。至于Go新版本是否会默认关闭DWARF生成,很可能不会,因为这也会增加调试的成本,制品库、代码版本、调试符号信息一一对应的管理成本。在存储成本低廉的今天,默认关闭DWARF调试信息生成的策略,可能是一个按下葫芦起了瓢的做法,对实践并不见的特别有价值。 当然如果你想显示关闭DWARF调试信息生成,可以通过 `go build -ldflags='-w'` 来关闭调试信息生成。 ================================================ FILE: book/8-dwarf/5-other/7-elf-sections.md ================================================ ## ELF Sections 虽然DWARF设计上可以与任何目标文件格式一起使用,但最经常与ELF一起使用,作者提供的示例也主要是基于Linux的。 DWARF调试信息根据描述对象的不同,在最终存储的时候也进行了归类、存储到不同的section。section名称均以前缀 `.debug_`开头。为了提升效率,对DWARF数据的大多数引用都是通过相对于当前编译单元的偏移量来引用的,而不是重复存储或者遍历之类的低效操作。 常见的ELF sections及其存储的内容如下: 1. .debug_abbrev, 存储.debug_info中使用的缩写信息; 2. .debug_arranges, 存储一个加速访问的查询表,通过内存地址查询对应编译单元信息; 3. .debug_frame, 存储调用栈帧信息; 4. .debug_info, 存储核心DWARF数据,包含了描述变量、代码等的DIEs; 5. .debug_line, 存储行号表程序 (程序指令由行号表状态机执行,执行后构建出完整的行号表) 6. .debug_loc, 存储location描述信息; 7. .debug_macinfo, 存储宏相关描述信息; 8. .debug_pubnames, 存储一个加速访问的查询表,通过名称查询全局对象和函数; 9. .debug_pubtypes, 存储一个加速访问的查询表,通过名称查询全局类型; 10. .debug_ranges, 存储DIEs中引用的address ranges; 11. .debug_str, 存储.debug_info中引用的字符串表,也是通过偏移量来引用; 12. .debug_types, 存储描述数据类型相关的DIEs; 这些信息都存储在.debug_前缀的sections中,它们之间的引用关系入下图 (DWARFv4 Appendix B) 所示,大家先有个直观的认识。注意DWARF v5有些变化,比如.debug_types废弃,.debug_pubnames, .debug_pubtypes 使用 .debug_names代替等,但是Go从1.12开始主要使用的是DWARF v4,所以从v4到v5的变化,我们了解即可。 dwarfv4-sections 新版本的编译器、链接器在生成DWARF调试信息时,会希望压缩二进制文件的尺寸,有可能会针对性地开启数据压缩,如Go新版本支持对调试信息做压缩,如 `-ldflags='-dwarfcompress=true'`,默认是true。最初,压缩后的debug sections会被写入.zdebug_前缀的sections中,而非.debug_前缀的sections,现在Go新版本也已经做了调整,默认会开压缩,压缩后也写入.debug_前缀的sections,是否开启压缩以及具体的压缩算法以Section Flags的方式来进行设置。 为了能和不支持解压缩的调试器进行更好的兼容: - Go旧版本:压缩后的DWARF数据会写入 `.zdebug_`为前缀的sections中,如 `.zdebug_info`,不会再将数据写入 `.debug_`为前缀的sections,以免解析DWARF数据异常、调试异常; - Go新版本:一般会提供选项来关闭压缩,如指定链接器选项 `-ldflags=-dwarfcompress=false`来阻止对调试信息进行压缩; 为了更好地学习掌握DWARF(或者ELF),掌握一些常用的工具是必不可少的,如 `readelf --debug-dump=
`、`objdump --dwarf=
`、dwarfdump、nm。另外,我亲手写了一个可视化工具:[hitzhangjie/dwarfviewer](https://github.com/hitzhangjie/dwarfviewer),目前支持导航式浏览DIE信息,也支持查看编译单元的行号信息表等,推荐作者使用该工具来辅助学习。 > ps:Github也找到一些个人开发者、小团队维护的专门针对DWARF的可视化工具,如dwex、dwarftree, dwarfexplorer、dwarfview等,但是使用后体验都不是很好,比如长期缺乏更新、依赖管理混乱难以安装使用、功能单一无法满足功能需求,最后没有一个顺利跑起来的。所以最后我才自己编写的dwarfviewer这个工具。 ================================================ FILE: book/8-dwarf/5-other/README.md ================================================ ## 其他调试数据 我们在8.3节中提到了通过DIE描述变量、数据类型、可执行代码。8.4节要描述的调试信息不是DIE能描述的,这些信息也不出现在.debug_info section中,这些信息对于符号级调试也是至关重要的。 这几种重要的调试信息,包括:1)加速访问表 2)行号表 3)宏信息;4)调用栈信息。和存储DIE数据面临类似的问题,这些表数据数据量也很大,也需要结合一定的编码策略优化存储。除了各个表特有的编码方式外,我们也会介绍一些DWARF数据共有的编码方式。 ### 重要的表数据 #### 加速访问(Accelerated Access) 调试器经常需要根据符号名、类型名、指令地址,快速定位到对应的DIE或者源码位置,比较笨的办法是遍历所有的DIEs,检查查询关键字符号名、类型名与DIEs描述的是否匹配,或者检查指令地址与对应的DIEs所表示的地址范围是否有包含关系。这是个办法,但是效率实在太低了,会影响调试时的体验、效率。 为了加速查询效率,DWARF在生成调试信息时会创建三个加速查询表: - .debug_pubnames:输入全局对象或函数的符号名,快速定位到对应的DIE。比如输入"main"可以直接找到main函数的DIE,而不用遍历所有DIE。 - .debug_pubtypes:输入类型名称,快速定位到描述该类型的DIE。比如输入"struct point"可以直接找到该结构体类型的DIE。 - .debug_aranges:输入指令地址,快速定位到包含该地址的编译单元。这对于根据程序计数器(PC)查找对应的源码位置很有帮助。 #### 行号表(Line Number Table) DWARF行号表 (.debug_line),包含了可执行程序机器指令的内存地址和对应的源代码行之间的映射关系。调试器需要这种映射来在用户单步执行程序时,将当前执行的机器指令地址转换为对应的源代码行,从而在源码中显示当前执行位置。行号表通常以字节码指令的形式存储,这些指令由行号表状态机执行,以生成完整的行号表。这种设计使得行号表可以高效地表示大量的地址到行号的映射,同时节省存储空间。 行号表中PC和源码位置的映射关系并不是简单的一对一关系,而是具有相当的复杂性。首先,一个源码行可能对应多条机器指令,这些指令在内存中可能不连续;其次,由于编译优化,机器指令的执行顺序可能与源码行的顺序不一致,比如循环展开、指令重排等优化会导致这种不一致;另外,内联函数、模板实例化、宏展开等特性也会使得一个源码位置对应多个PC地址,或者一个PC地址对应多个源码位置。DWARF行号表通过状态机的方式,使用一系列指令来描述这些复杂的映射关系,包括设置文件、设置行号、设置列号、设置指令地址等操作,从而能够准确记录这些复杂的对应关系。 #### 宏信息(Macro Information) 大多数调试器很难显示和调试具有宏的代码。比如比较常见的问题是,用户看的是带有宏的原始源文件,而代码则对应于宏展开后的东西。DWARF调试信息中包含了对程序中定义的宏的描述。宏信息通常存储在 .debug_macro section 中,它记录了宏的定义、参数、展开后的内容以及宏定义的位置。调试器可以利用这些信息在调试过程中显示宏的实际展开内容,帮助开发者理解宏的行为和调试宏相关的问题。这对于使用大量宏的代码库尤为重要,因为宏的展开可能引入复杂的逻辑和潜在的错误。 C\C++是支持宏的编程语言,因此C\C++程序调试就比较依赖这部分调试信息支持。Go语言设计者有意废弃宏这种东西,而通过go generate、接口和组合、反射、泛型来提供相应的能力支持,所以我们后续不用在这部分倾注过多精力。 #### 调用栈信息(Call Frame Information) 调用栈信息(CFI, Call Frame Information)是DWARF调试信息的一部分,通常存储在.debug_frame或.eh_frame section中。它描述了程序执行过程中栈帧的布局和变化,包括寄存器保存、栈指针调整以及如何恢复调用者的栈帧。CFI以表格或指令序列的形式存储,这些指令由调试器解释以重建调用栈。 在调试过程中,调试器需要知道当前执行的函数是如何被调用的,以及如何访问函数的参数和局部变量。CFI提供了这些信息,使得调试器能够正确地展开调用栈,显示函数调用链,并帮助开发者理解程序的执行流程。这对于调试复杂的程序,尤其是涉及递归或异常处理的程序,尤为重要。 CFI通过一系列指令来描述栈帧的变化,这些指令包括: - **CFA(Canonical Frame Address)**:定义当前栈帧的基地址,通常指向调用者的栈帧顶部。 - **寄存器规则**:描述如何恢复寄存器的值,例如,某些寄存器可能被保存在栈上。 - **栈指针调整**:描述栈指针如何变化,以反映函数调用和返回时的栈帧调整。 调试器通过解释这些指令,可以重建调用栈,确定每个函数的栈帧位置,从而访问函数的参数和局部变量。这种机制使得调试器能够在程序执行过程中动态地展开调用栈,提供准确的调试信息。 ### 常用的编码方式 DWARF不同类型的数据需要考虑编码方式以减少存储占用,除了前面需要单独介绍的DIE数据编码以及几种重要的信息表的数据编码外,还有一些共用的编码方式。 #### 变长数据(Variable Length Data) 在整个DWARF调试信息表示中,整数值使用的非常广泛,从数据段中的偏移量,到数组长度、结构体大小,等等。由于大多数整数的实际值可能比较小,只用几位就可以表示,这意味着整数值的高位bits很多由零组成,那能否优化编码方式来节省存储占用呢?protobuf使用zigzag编码对整数进行编码,熟悉protobuf的读者应该不陌生。那我们看看DWARF调试信息是如何实现的。 DWARF定义了一种可变长度的整数,称为**Little Endian Base 128**(带符号整数为LEB128或无符号整数为ULEB128),LEB128可以压缩占用的字节来表示整数值,对于小整数值比较多的情况下,无疑会节省存储空间。关于LEB128的内容,可以参考Wiki: https://en.wikipedia.org/wiki/LEB128。 #### 压缩DWARF数据(Shrinking DWARF data) 与DWARF v1相比,DWARF新版本使用的编码方案大大减少了调试信息的大小。但不幸的是,编译器生成的调试信息仍然很大,通常大于可执行代码和数据的存储占用。DWARF新版本提供了进一步减少调试数据大小的方法,比如使用zlib数据压缩。 ### 其他debug sections DWARF调试信息根据描述对象的不同,在最终存储的时候也进行了归类、存储到不同的地方。以ELF文件格式为例,DWARF调试信息被存储到了不同的section中,section名称均以前缀'.debug_'开头,例如,.debug_frame包含调用栈信息,.debug_info包含核心DWARF数据(如DIE描述的变量、可执行代码等),.debug_types包含定义的类型,.debug_line包含行号表程序(字节码指令,由行号表状态机执行以生成完整行号表)。 由于篇幅原因,难以在一个章节里面覆盖DWARF调试信息标准的所有细节,要知道单单DWARF v4内容就有325 pages。要更加深入细致地了解这部分内容,就需要阅读DWARF调试信息标准了。 ================================================ FILE: book/8-dwarf/6-practices.md ================================================ ## DWARF解析及应用 前面我们系统性介绍了DWARF调试信息标准的方方面面,它是什么,由谁生成,它如何描述不同的数据、类型、函数,如何描述指令地址与源码位置的映射关系,如何展开调用栈,以及具体的设计实现,等等,可以说我们对DWARF的那些高屋建瓴的设计,已经有了一定的认识。 接下来就要准备实践阶段了,在进入下一章开始开发之前,我们先了解下当前go的主流调试器go-delve/delve中对DWARF数据的读写支持,然后我们写几个测试用例验证下DWARF可以帮助我们获取到哪些信息。 ### DWARF解析 介绍下[go-delve/delve](https://github.com/go-delve/delve)中的DWARF解析相关的代码,这里简单介绍下相关package的作用和使用方法,在后续小节中将有更详细的使用。 这里的介绍采用的delve源码版本为:commit cba1a524。您可以检出delve的源码的对应版本,来进一步深入了解,我们先跟随作者的节奏来快速了解。 #### 目录结构 我们先看下delve中DWARF相关的代码,这部分代码位于项目目录下的pkg/dwarf目录下,根据描述的DWARF信息的不同、用途的不同又细分为了几个不同的package。 我们用tree命令来先试下pkg/dwarf这个包下的目录及文件列表: ```go ${path-to-delve}/pkg/dwarf/ ├── dwarfbuilder │   ├── builder.go │   ├── info.go │   └── loc.go ├── frame │   ├── entries.go │   ├── entries_test.go │   ├── expression_constants.go │   ├── parser.go │   ├── parser_test.go │   ├── table.go │   └── testdata │   └── frame ├── godwarf │   ├── addr.go │   ├── sections.go │   ├── tree.go │   ├── tree_test.go │   └── type.go ├── line │   ├── _testdata │   │   └── debug.grafana.debug.gz │   ├── line_parser.go │   ├── line_parser_test.go │   ├── parse_util.go │   ├── state_machine.go │   └── state_machine_test.go ├── loclist │   ├── dwarf2_loclist.go │   ├── dwarf5_loclist.go │   └── loclist5_test.go ├── op │   ├── op.go │   ├── op_test.go │   ├── opcodes.go │   ├── opcodes.table │   └── regs.go ├── reader │   ├── reader.go │   └── variables.go ├── regnum │   ├── amd64.go │   ├── arm64.go │   └── i386.go └── util ├── buf.go ├── util.go └── util_test.go 11 directories, 37 files ``` #### 功能说明 对上述package的具体功能进行简单陈述: | package | 作用及用途 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | dwarfbuilder | 实现了一个Builder,通过该Builder可以方便地生成不同代码结构对应的DWARF调试信息,如New()返回一个Builder并初始设置DWARF信息的header字段,然后通过返回的builder增加编译单元、数据类型、变量、函数等等。`
`可以说,这个Builder为快速为源码生成对应的调试信息提供了很大遍历。但是这个package对于实现调试器而言应该是没多大用处的,但是对于验证go编译工具链如何生成调试信息很有帮助。一旦能认识到go编译工具链是如何生成DWARF调试信息的,我们就可以进一步了解到该如何去解析、应用对应的调试信息。`
`这个package的作用更多地是用于学习、验证DWARF调试信息生成和应用的。 | | frame | .[z]debug_frame中的信息可以帮助构建CFI (Canonical Frame Information),指定任意指令地址,我们便可以借助CFI计算出当前的调用栈。`
`DWARF信息中的编译单元可能压缩了多个go源文件,每个编译单元都以CIE (Common Information Entry) 开始,然后接下来是一系列的FDE (Frame Description Entry)。`
`这里定义了类型CommonInformationEntry、FrameDescriptionEntry用来分别表示CIE、FDE。FDE里面引用CIE,CIE中包含了初始指令序列,FDE中包含了自己的指令序列,结合CIE、FDE可以构建出完整的CFI表。`
`为了方便判断某个指令地址是否在某个FDE范围内,类型FrameDescriptionEntry中定义了方法Cover,还提供了Begin、End来给出该FDE的范围,此外它还定义了方法EstablishFrame通过状态机执行CIE、FDE中的指令序列来按需构建CFI表的全部或者一部分,方便我们计算CFA (Canonical Frame Address) ,有了它可以进一步计算出被调函数的返回地址。`
`有了这个返回地址,它实际是个指令地址,我们就可以计算出对应的源码位置(如文件名、行号、函数名)。将这个返回地址继续作为指令地址去迭代处理,我们就可以计算出完整的调用栈。`

`**注意:FDE中的begin、end描述的是创建、销毁栈帧及其存在期间的指令序列instructions的地址范围,详见DWARF v4 standard。**`
`此外还定义了类型FrameDescriptionEntries,它实际上是一个FDE的slice,只是增加了一些帮助函数,比如FDEForPC用于通过指令地址查询包含它的FDE。`
`每个函数都有一个FDE,每个函数的每条指令都是按照定义时的顺序来安排虚拟的内存地址的,不存在一个函数的FDE的指令范围会包括另一个函数的FDE的指令范围的情况)。 | | godwarf | 这个包提供了一些基础的功能,addr.go中提供了DWARF v5中新增的.[z]debug_addr的解析能力。`
`sections.go中提供了读取不同文件格式中调试信息的功能,如GetDebugSectionElf能从指定elf文件中读取指定调试section的数据,并且根据section数据是否压缩自动解压缩处理。`
`tree.go提供了读取DIE构成的Tree的能力,一个编译单元如果不连续的话在Tree.Ranges中就存在多个地址范围,当判断一个编译单元的地址范围是否包含指定指令地址时就需要遍历Tree.Ranges进行检查,Tree.ContainsPC方法简化了这个操作。Tree.Type方法还支持读取当前TreeNode对应的类型信息。`
`type.go中定义了对应go数据类型的一些类型,包括基本数据类型BasicType以及基于组合扩展的CharType、UcharType、IntType等,也包括一些组合类型如StructType、SliceType、StringType等,还有其他一些类型。这些类型都是以DIE的形式存储在.[z]debug_info中的。tree.go中提供了一个非常重要的函数ReadType,它能从DWARF数据中读取定义在指定偏移量处的类型信息,并在对应类型中通过reflect.Kind来建立与go数据类型的对应关系,以后就可以很方便地利用go的reflect包来创建变量并赋值。 | | line | 符号级调试很重要的一点是能够在指令地址与源文件名:行号之间进行转换,比如添加给语句添加断点的时候要转化成对指令地址的指令patch,或者停在某个断点处时应该显示出当前停在的源代码位置。行号表就是用来实现这个转换的,行号表被编码为一个字节码指令流,存储在.[z]debug_line中。`
`每个编译单元都有一个行号表,不同的编译单元的行号表数据最终会被linker合并在一起。每个行号表都有固定的结构以供解析,如header字段,然后后面跟着具体数据。`
`line_parser.go中提供了方法ParseAll来解析.[z]debug_line中的所有编译单元的行号表,对应类型DebugLines表示,每个编译单元对应的行号对应类型DebugLineInfo。DebugLineInfo中很重要的一个字段就是指令序列,这个指令序列也是交给一个行号表状态机去执行的,状态机实现定义在state_machine.go中,状态机执行后就能构建出完整的行号表。`
`有了完整的行号表,我们就可以根据pc去查表来找到对应的源码行。 | | loclist | 描述对象在内存中的位置可以用位置表达式,也可以用位置列表。如果在对象生命周期中对象的位置可能发生变化,那么就需要一个位置列表来描述。再者,如果一个对象在内存中的存储不是一个连续的段,而是多个不相邻的段合并起来,那这种也需要用位置列表来描述。`
`在DWARF v2~v4中,位置列表信息存储在.[z]debug_loc中,在DWARF v5中,则存储在.[z]debug_loclist中。loclist包分别针对旧版本(DWARF v2~v4)、新版本(DWARF v5)中的位置列表予以了支持。`
`这个包中定义了Dwarf2Reader、Dwarf5Reader分别用来从旧版本、新版本的位置列表原始数据中读取位置列表。 | | op | 先看op.go,DWARF中前面讲述地址表达式的运算时,提到了地址运算是通过执行一个基于栈操作的程序指令列表来完成的。程序指令都是1字节码指令,这里的字节码在当前package中均有定义,其需要的操作数就在栈中,每个字节码指令都有一个对应的函数stackfn,该函数执行时会对栈中的数据进行操作,取操作数并将运算结果重新入栈。最终栈顶元素即结果。`
`opcodes.go中定义了一系列操作码、操作码到名字映射、操作码对应操作数数量。`
`registers.go定义了DWARF关心的寄存器列表的信息DwarfRegisters,还提供了一些遍历的方法,如返回指定编号对应的的寄存器信息DwarfRegister、返回当前PC/SP/BP寄存器的值。 | | reader | 该包定义了类型Reader,它内嵌了go标准库中的dwarf.Reader来从.[z]debug_info中读取DIE信息,每个DIE在DWARF中被组织成一棵树的形式,每个DIE对应一个dwarf.Entry,它包括了此前提及的Tag以及[]Field(Field中记录了Attr信息),此外还记录了DIE的Offset、是否包含孩子DIE。`
`这里的Reader,还定义了一些其他函数如Seek、SeekToEntry、AddrFor、SeekToType、NextType、SeekToTypeNamed、FindEntryNamed、InstructionsForEntryNamed、InstructionsForEntry、NextMemberVariable、NextPackageVariable、NextCompileUnit。`
`该包还定义了类型Variable,其中嵌入了描述一个变量的DIE构成的树godwarf.Tree。它还提供了函数Variables用来从指定DIE树中提取包含的变量列表。 | | regnum | 定义了寄存器编号与寄存器名称的映射关系,提供了函数快速双向查询。 | | leb128 | 实现了几个工具函数:从一个sleb128编码的reader中读取一个int64;从一个uleb128编码的reader中读取一个uint64;对一个int64按sleb128编码后写入writer;对一个uint64按uleb128编码后写入writer。 | | dwarf | 实现了几个工具函数:从DWARF数据中读取基本信息(长度、dwarf64、dwarf版本、字节序),读取包含的编译单元列表及对应的版本信息,从buffer中读取DWARF string,从buffer中按指定字节序读取Uint16、Uint32、Uint64,按指定字节序编码一个Uint32、Uint64并写入buffer。 | `github.com/go-delve/delve/pkg/dwarf`,沉淀了delve对DWARF数据读写操作的支持。手写一个完备的DWARF解析库,要精通DWARF调试信息标准,还要了解go编译工具链在从DWARF v4演变到DWARF v5的过程中所做的各种调整,工作量还是很大的。为了避免大家学习过程过于枯燥,我们不会再手写一个新的DWARF支持库,而是复用go-delve/delve中的实现(可能会适当裁剪,并在必要时进行强调)。 ### DWARF应用 本小节相关代码您可以从这里获取:https://github.com/hitzhangjie/codemaster/tree/master/dwarf/test。 #### ELF读取DWARF ELF文件中读取DWARF相关的调试section,并打印section名称及数据量大小: ```go func Test_ElfReadDWARF(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) sections := []string{ "abbrev", "line", "frame", "pubnames", "pubtypes", //"gdb_script", "info", "loc", "ranges", } for _, s := range sections { b, err := godwarf.GetDebugSection(f, s) assert.Nil(t, err) t.Logf(".[z]debug_%s data size: %d", s, len(b)) } } ``` fixtures/elf_read_dwarf由以下源程序编译而来: ```go package main import "fmt" func main() { fmt.Println("vim-go") } ``` `go test -v`运行结果如下: ```bash $ go test -v === RUN Test_ElfReadDWARF dwarf_test.go:31: .[z]debug_abbrev data size: 486 dwarf_test.go:31: .[z]debug_line data size: 193346 dwarf_test.go:31: .[z]debug_frame data size: 96452 dwarf_test.go:31: .[z]debug_pubnames data size: 13169 dwarf_test.go:31: .[z]debug_pubtypes data size: 54135 dwarf_test.go:31: .[z]debug_info data size: 450082 dwarf_test.go:31: .[z]debug_loc data size: 316132 dwarf_test.go:31: .[z]debug_ranges data size: 76144 --- PASS: Test_ElfReadDWARF (0.01s) PASS ok github.com/hitzhangjie/codemaster/dwarf/test 0.015s ``` #### 读取类型定义 仍以上面的elf_read_dwarf为例,读取其中定义的所有类型: ```go func Test_DWARFReadTypes(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := f.DWARF() assert.Nil(t, err) rd := reader.New(dat) for { e, err := rd.NextType() if err != nil { break } if e == nil { break } t.Logf("read type: %s", e.Val(dwarf.AttrName)) } } ``` `go test -run Test_DWARFReadTypes -v`运行结果如下: ``` $ go test -run Test_DWARFReadTypes -v === RUN Test_DWARFReadTypes dwarf_test.go:54: read type: dwarf_test.go:54: read type: unsafe.Pointer dwarf_test.go:54: read type: uintptr dwarf_test.go:54: read type: runtime._type dwarf_test.go:54: read type: runtime._type dwarf_test.go:54: read type: uint32 dwarf_test.go:54: read type: runtime.tflag dwarf_test.go:54: read type: uint8 dwarf_test.go:54: read type: func(unsafe.Pointer, unsafe.Pointer) bool dwarf_test.go:54: read type: func(unsafe.Pointer, unsafe.Pointer) bool dwarf_test.go:54: read type: bool dwarf_test.go:54: read type: *bool dwarf_test.go:54: read type: *uint8 dwarf_test.go:54: read type: runtime.nameOff dwarf_test.go:54: read type: runtime.typeOff ... dwarf_test.go:54: read type: waitq dwarf_test.go:54: read type: *sudog dwarf_test.go:54: read type: hchan dwarf_test.go:54: read type: *hchan --- PASS: Test_DWARFReadTypes (0.06s) PASS ok github.com/hitzhangjie/codemaster/dwarf/test 0.067s ``` 这里,我们没有显示类型具体定义在哪个源文件中,如果想获取所处源文件的话,需要结合编译单元对应的DIE来完成。 我们在elf_read_dwarf.go中加一个自定义类型 `type Student struct{}`,然后编译。接着我们重新修改下测试代码: ```go func Test_DWARFReadTypes2(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := f.DWARF() assert.Nil(t, err) var cuName string var rd = reader.New(dat) for { entry, err := rd.Next() if err != nil { break } if entry == nil { break } switch entry.Tag { case dwarf.TagCompileUnit: cuName = entry.Val(dwarf.AttrName).(string) t.Logf("- CompilationUnit[%s]", cuName) case dwarf.TagArrayType, dwarf.TagBaseType, dwarf.TagClassType, dwarf.TagStructType, dwarf.TagUnionType, dwarf.TagConstType, dwarf.TagVolatileType, dwarf.TagRestrictType, dwarf.TagEnumerationType, dwarf.TagPointerType, dwarf.TagSubroutineType, dwarf.TagTypedef, dwarf.TagUnspecifiedType: t.Logf(" cu[%s] define [%s]", cuName, entry.Val(dwarf.AttrName)) } } } ``` `go test -run Test_DWARFReadTypes2`运行结果如下: ```bash $ go test -run Test_DWARFReadTypes2 dwarf_test.go:80: - CompilationUnit[sync] dwarf_test.go:80: - CompilationUnit[internal/cpu] dwarf_test.go:80: - CompilationUnit[runtime/internal/sys] dwarf_test.go:80: - CompilationUnit[fmt] dwarf_test.go:80: - CompilationUnit[runtime/internal/atomic] ... dwarf_test.go:94: cu[runtime] define [fmt.Stringer] dwarf_test.go:94: cu[runtime] define [main.Student] dwarf_test.go:94: cu[runtime] define [[]strconv.leftCheat] ... ``` 可以看到输出结果中显示编译单元runtime中定义了类型main.Student,奇怪了为什么是编译单元runtime中而非main,源码中命名是main.Student定义在package main中的。这里的编译单元可能会合并多个go源文件对应的目标文件,因此这个问题也就好理解了。 我们现在还可以按照类型名定位对应的类型DIE: ```go func Test_DWARFReadTypes3(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := f.DWARF() assert.Nil(t, err) var rd = reader.New(dat) entry, err := rd.SeekToTypeNamed("main.Student") assert.Nil(t, err) fmt.Println(entry) } ``` `go test -v -run Test_DWARFReadTypes3`运行测试结果如下: ```bash go test -run Test_DWARFReadTypes3 -v === RUN Test_DWARFReadTypes3 &{275081 StructType true [{Name main.Student ClassString} {ByteSize 0 ClassConstant} {Attr(10496) 25 ClassConstant} {Attr(10500) 59904 ClassAddress}]} --- PASS: Test_DWARFReadTypes3 (0.02s) PASS ok github.com/hitzhangjie/codemaster/dwarf/test 0.020s ``` 这里的类型信息如何理解呢?这就需要结合前面讲过的DWARF如何描述数据类型相关的知识点慢慢进行理解了。不用担心,后面我们仍然会遇到这里的知识点,到时候会再次结合相关知识点来描述。 #### 读取变量 现在读取变量定义对我们来说也不是什么难事了,我们来看个示例: ```go package main import "fmt" type Student struct{} func main() { s := Student{} fmt.Println(s) } ``` 现在我们尝试获取上述main中的变量s的信息: ```go func Test_DWARFReadVariable(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := f.DWARF() assert.Nil(t, err) var rd = reader.New(dat) for { entry, err := rd.Next() if err != nil { break } if entry == nil { break } // 只查看变量 if entry.Tag != dwarf.TagVariable { continue } // 只查看变量名为s的变量 if entry.Val(dwarf.AttrName) != "s" { continue } // 通过offset限制,只查看main.main中定义的变量名为s的变量 // 这里的0x432b9是结合`objdump --dwarf=info`中的结果来硬编码的 if entry.Val(dwarf.AttrType).(dwarf.Offset) != dwarf.Offset(0x432b9) { continue } // 查看变量s的DIE fmt.Println("found the variable[s]") fmt.Println("DIE variable:", entry) // 查看变量s对应的类型的DIE ee, err := rd.SeekToType(entry, true, true) assert.Nil(t, err) fmt.Println("DIE type:", ee) // 查看变量s对应的地址 [lowpc, highpc, instruction] fmt.Println("location:", entry.Val(dwarf.AttrLocation)) // 最后在手动校验下main.Student的类型与上面看到的变量的类型是否一致 // 应该满足:main.Student DIE的位置 == 变量的类型的位置偏移量 typeEntry, err := rd.SeekToTypeNamed("main.Student") assert.Nil(t, err) assert.Equal(t, typeEntry.Val(dwarf.AttrType), variableTypeEntry.Offset) break } } ``` 上面我们查看了变量的DIE、对应类型的DIE、该变量的内存地址,运行 `go test -run Test_DWARFReadVariable -v`查看运行结果: ```bash $ go test -run Test_DWARFReadVariable -v === RUN Test_DWARFReadVariable found the variable[s] DIE variable: &{324895 Variable false [{Name s ClassString} {DeclLine 11 ClassConstant} {Type 275129 ClassReference} {Location [145 168 127] ClassExprLoc}]} DIE type: &{275081 StructType true [{Name main.Student ClassString} {ByteSize 24 ClassConstant} {Attr(10496) 25 ClassConstant} {Attr(10500) 74624 ClassAddress}]} location: [145 168 127] --- PASS: Test_DWARFReadVariable (0.02s) PASS ok github.com/hitzhangjie/codemaster/dwarf/test 0.023s ``` 注意,在上述测试用例的尾部,我们还校验了变量 `s:=main.Student{}`的类型定义的位置偏移量与类型 `main.Student`的定义位置进行了校验。 #### 读取函数定义 现在读取下程序中的函数、方法、匿名函数的定义: ```go func Test_DWARFReadFunc(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := f.DWARF() assert.Nil(t, err) rd := reader.New(dat) for { die, err := rd.Next() if err != nil { break } if die == nil { break } if die.Tag == dwarf.TagSubprogram { fmt.Println(die) } } } ``` 运行命令 `go test -v -run Test_DWARFReadFunc`进行测试,我们看到输出了程序中定义的一些函数,也包括我们main package中的函数main.main。 ```bash $ go test -v -run Test_DWARFReadFunc === RUN Test_DWARFReadFunc &{73 Subprogram true [{Name sync.newEntry ClassString} {Lowpc 4725024 ClassAddress} {Highpc 4725221 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 3 ClassConstant} {External true ClassFlag}]} &{149 Subprogram true [{Name sync.(*Map).Load ClassString} {Lowpc 4725248 ClassAddress} {Highpc 4726474 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 3 ClassConstant} {External true ClassFlag}]} &{272 Subprogram true [{Name sync.(*entry).load ClassString} {Lowpc 4726496 ClassAddress} {Highpc 4726652 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 3 ClassConstant} {External true ClassFlag}]} &{368 Subprogram true [{Name sync.(*Map).Store ClassString} {Lowpc 4726656 ClassAddress} {Highpc 4728377 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 3 ClassConstant} {External true ClassFlag}]} ... &{324861 Subprogram true [{Name main.main ClassString} {Lowpc 4949568 ClassAddress} {Highpc 4949836 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 2 ClassConstant} {External true ClassFlag}]} ... &{450220 Subprogram true [{Name reflect.methodValueCall ClassString} {Lowpc 4856000 ClassAddress} {Highpc 4856091 ClassAddress} {FrameBase [156] ClassExprLoc} {DeclFile 1 ClassConstant} {External true ClassFlag}]} --- PASS: Test_DWARFReadFunc (41.67s) PASS ok github.com/hitzhangjie/codemaster/dwarf/test 41.679s ``` go程序中除了上述tag为DW_TAG_subprogram的DIE与函数有关,DW_TAG_subroutine_type、DW_TAG_inlined_subroutine_type、DW_TAG_inlined_subroutine也与之有关,后面有机会再展开介绍。 #### 读取行号表信息 现在尝试读取程序中的行号表信息: ```go func Test_DWARFReadLineNoTable(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) dat, err := godwarf.GetDebugSection(f, "line") assert.Nil(t, err) lineToPCs := map[int][]uint64{10: nil, 12: nil, 13: nil, 14: nil, 15: nil} debuglines := line.ParseAll(dat, nil, nil, 0, true, 8) fmt.Println(len(debuglines)) for _, line := range debuglines { //fmt.Printf("idx-%d\tinst:%v\n", line.Instructions) line.AllPCsForFileLines("/root/dwarftest/dwarf/test/fixtures/elf_read_dwarf.go", lineToPCs) } for line, pcs := range lineToPCs { fmt.Printf("lineNo:[elf_read_dwarf.go:%d] -> PC:%#x\n", line, pcs) } } ``` 我们首先读取测试程序fixtures/elf_read_dwarf这个文件,然后从中提取.[z]debug_line section,然后调用 `line.ParseAll(...)`来解析.[z]debug_line中的数据,这个函数只是解析行号表序言然后将行号表字节码指令读取出来,并没有真正执行字节码指令来构建行号表。 什么时候构建行号表呢?当我们按需进行查询时,line.DebugLines内部就会通过内部的状态机来执行字节码指令,完成这张虚拟的行号表的构建。 在上述测试文件 `fixtures/elf_read_dwarf`对应的go源文件为: ```go 1:package main 2: 3:import "fmt" 4: 5:type Student struct { 6: Name string 7: Age int 8:} 9: 10:type Print func(s string, vals ...interface{}) 11: 12:func main() { 13: s := Student{} 14: fmt.Println(s) 15:} ``` 我们取上述源文件中的第10、12、13、14、15行还用来查询其对应的指令的PC值,`line.AllPCsForFileLines`将协助完成这项操作,并将结果存储到传入的map中。然后我们将这个map打印出来。 运行测试命令 `go test -run Test_DWARFReadLineNoTable -v`,运行结果如下: ```bash $ go test -run Test_DWARFReadLineNoTable -v === RUN Test_DWARFReadLineNoTable 41 lineNo:[elf_read_dwarf.go:12] -> PC:[0x4b8640 0x4b8658 0x4b8742] lineNo:[elf_read_dwarf.go:13] -> PC:[0x4b866f] lineNo:[elf_read_dwarf.go:14] -> PC:[0x4b8680 0x4b86c0] lineNo:[elf_read_dwarf.go:15] -> PC:[0x4b8729] lineNo:[elf_read_dwarf.go:10] -> PC:[] --- PASS: Test_DWARFReadLineNoTable (0.00s) PASS Process finished with the exit code 0 ``` 我们可以看到源码中的lineno被映射到了对应的PC slice,因为有的源码语句可能对应着多条机器指令,指令地址当然也就有多个,这个很好理解,先不深究。可是按我们之前理解的行号表设计,每个行号处,只保留一个指令地址就可以了,为什么这里会有多个指令地址呢? 我们先看下 `elf_read_dwarf.go:12`,这一行对应着3条指令的PC值,为什么呢?我们先反汇编看下这几条指令地址处是什么。 运行 `objdump -dS fixtures/elf_read_dwarf`,并在里面检索上述几个地址,图中已用符号>标注)。 ```bash func main() { > 4b8640: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx 4b8647: ff ff 4b8649: 48 8d 44 24 e8 lea -0x18(%rsp),%rax 4b864e: 48 3b 41 10 cmp 0x10(%rcx),%rax 4b8652: 0f 86 ea 00 00 00 jbe 4b8742 > 4b8658: 48 81 ec 98 00 00 00 sub $0x98,%rsp 4b865f: 48 89 ac 24 90 00 00 mov %rbp,0x90(%rsp) 4b8666: 00 4b8667: 48 8d ac 24 90 00 00 lea 0x90(%rsp),%rbp 4b866e: 00 s := Student{} 4b866f: 0f 57 c0 xorps %xmm0,%xmm0 4b8672: 0f 11 44 24 48 movups %xmm0,0x48(%rsp) 4b8677: 48 c7 44 24 58 00 00 movq $0x0,0x58(%rsp) 4b867e: 00 00 fmt.Println(s) 4b8680: 0f 57 c0 xorps %xmm0,%xmm0 ... ... 4b873e: 66 90 xchg %ax,%ax 4b8740: eb ac jmp 4b86ee func main() { > 4b8742: e8 b9 36 fb ff callq 46be00 4b8747: e9 f4 fe ff ff jmpq 4b8640 4b874c: cc int3 4b874d: cc int3 ``` 这几条指令地址处确实比较特殊: - 0x4b8640,该地址是函数的入口地址; - 0x4b8742,该地址对应的是runtime.morestack_noctxt的位置,对go协程栈有过了解的都清楚,该函数会检查是否需要将当前函数的栈帧扩容; - 0x4b8658,该地址则是在按需扩容栈帧后的分配栈帧动作; 虽然这几个地址比较特殊,看上去也比较重要,但是为什么会关联3个PC值还是让人费解,我们继续看下elf_read_dwarf.go:14,并检索对应的指令位置(图中已用符号>标注)。 ```bash fmt.Println(s) > 4b8680: 0f 57 c0 xorps %xmm0,%xmm0 4b8683: 0f 11 44 24 78 movups %xmm0,0x78(%rsp) 4b8688: 48 c7 84 24 88 00 00 movq $0x0,0x88(%rsp) 4b868f: 00 00 00 00 00 4b8694: 0f 57 c0 xorps %xmm0,%xmm0 4b8697: 0f 11 44 24 38 movups %xmm0,0x38(%rsp) 4b869c: 48 8d 44 24 38 lea 0x38(%rsp),%rax 4b86a1: 48 89 44 24 30 mov %rax,0x30(%rsp) 4b86a6: 48 8d 05 d3 2c 01 00 lea 0x12cd3(%rip),%rax # 4cb380 4b86ad: 48 89 04 24 mov %rax,(%rsp) 4b86b1: 48 8d 44 24 78 lea 0x78(%rsp),%rax 4b86b6: 48 89 44 24 08 mov %rax,0x8(%rsp) 4b86bb: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1) > 4b86c0: e8 3b 27 f5 ff callq 40ae00 4b86c5: 48 8b 44 24 30 mov 0x30(%rsp),%rax 4b86ca: 84 00 test %al,(%rax) ``` 一起来看下这两条指令地址有什么特殊的: - 0x4b8680,该地址处的指令很明显是准备调用函数fmt.Println(s)前的一些准备动作,具体做什么也不用关心无非是准备参数、返回值这些; - 0x4b86c0,该地址处的指令很明显是准备调用运行时函数runtime.convT2E,应该是将string变量s转换成eface,然后再交给后续的fmt.Println去打印; 这么分析下来,一个lineno对应多个PC的情况下也没什么大问题,我们可以使用其中的任何一个作为断点来设置,这么想似乎也没什么不对,那为什么要有多个PC值呢? - 这是bug吗?应该不是,我认为这是go编译器、链接器有意这样生成的。 - 为什么这样生成呢?首先可以肯定的是,`line.AllPCsForFileLines`已经是根据行号表字节码指令运算出来的lineno到PC slice的映射关系了,算出来的结果也绝不是全量存储lineno对应的所有PC值。在此基础上考虑为什么会有多个PC。假设我们想对程序分析地更透彻一点,除了用户程序还可能包含go runtime等各种细节,如runtime.convT2E、runtime.morestack_noctxt,如果编译器、链接器指导生成的DWARF中包含了这样的字节码指令,有意让同一个lineno对应多个PC,我认为只可能是为了方便更精细化的调试,允许调试器不仅调试用户代码,也允许调试go runtime本身。 关于行号表的读取和说明就先到这,我们后续用到的时候会进一步展开。 #### 读取CFI表信息 接下来读取CFI(Call Frame Information)信息表: ```go func Test_DWARFReadCFITable(t *testing.T) { f, err := elf.Open("fixtures/elf_read_dwarf") assert.Nil(t, err) // 解析.[z]debug_frame中CFI信息表 dat, err := godwarf.GetDebugSection(f, "frame") assert.Nil(t, err) fdes, err := frame.Parse(dat, binary.LittleEndian, 0, 8, 0) assert.Nil(t, err) assert.NotEmpty(t, fdes) //for idx, fde := range fdes { // fmt.Printf("fde[%d], begin:%#x, end:%#x\n", idx, fde.Begin(), fde.End()) //} for _, fde := range fdes { if !fde.Cover(0x4b8640) { continue } fmt.Printf("address 0x4b8640 is covered in FDE[%#x,%#x]\n", fde.Begin(), fde.End()) fc := fde.EstablishFrame(0x4b8640) fmt.Printf("retAddReg: %s\n", regnum.AMD64ToName(fc.RetAddrReg)) switch fc.CFA.Rule { case frame.RuleCFA: fmt.Printf("cfa: rule:RuleCFA, CFA=(%s)+%#x\n", regnum.ARM64ToName(fc.CFA.Reg), fc.CFA.Offset) default: } } } ``` 我们首先读取elf文件中的.[z]debug_frame section,然后利用 `frame.Parse(...)`方法完成CFI信息表的解析,解析后的数据存储在类型为 `FrameDescriptionEntries`的变量fdes中,这个类型其实是 `type FrameDescriptionEntries []*FrameDescriptionEntry`,只不过在这个类型上增加了一些方便易用的方法,如比较常用的 `FDEForPC(pc)`用来返回FDE指令地址范围包含pc的那个FDE。 我们可以遍历fdes将每个fde的指令地址范围打印出来。 在读取行号表信息时,我们了解到0x4b8640这个地址为main.main的入口地址,我们不妨拿这条指令来进一步做下测试。我们遍历所有的FDE来检查到底哪个FDE的指令地址范围包含main.main入口指令0x4b8640。 > ps: 其实这里的遍历+fde.Cover(pc)可以通过通过fdes.FDEForPC代替,这里只是为了演示FrameDescriptionEntry提供了Cover方法。 当找到的时候,我们就检查要计算当前pc 0x4b8640对应的CFA(Canonical Frame Address)。估计对CFA的概念又不太清晰了,再解释下CFA的概念: > **DWARFv5 Call Frame Information L8:L12**: > > An area of memory that is allocated on a stack called a “call frame.” The call frame is identified by an address on the stack. We refer to this address as the Canonical Frame Address or CFA. Typically, the CFA is defined to be the value of the stack pointer at the call site in the previous frame (which may be different from its value on entry to the current frame). 有了这个CFA我们就可以找到当前pc对应的栈帧以及caller的栈帧,以及caller的caller的栈帧……每个函数调用对应的栈帧中都有返回地址,返回地址实际为指令地址,借助行号表我们又可以将指令地址映射为源码中的文件名和行号,这样就可以很直观地显示当前pc的调用栈信息。 当然,CFI信息表提供的不光是CFA的计算,它还记录了指令执行过程中对其他寄存器的影响,因此还可以显示不同栈帧中时寄存器的值。通过在不同栈帧中游走,还可以看到栈帧中定义的局部变量的值。 关于CFI的使用我们就先简单介绍到这,后面实现符号级调试时再进一步解释。 ### 本节小结 本小节我们介绍了 `github.com/go-delve/delve/pkg/dwarf` 的一些DWARF支持,然后使用这些包编写了一些测试用例,分别测试了读取数据类型定义、读取变量、读取函数定义、读取行号表、读取调用栈信息表,通过编写这些测试用例,我们加深了对DWARF解析以及应用的理解。 ================================================ FILE: book/8-dwarf/7-summary.md ================================================ ## 本章总结 DWARF的基本概念非常简单: - 程序被描述为“**DIE节点构成的树**”,以紧凑的语言和与机器无关的方式表示源码中的各种函数、数据和类型; - “**行号表**”提供了可执行指令地址和生成它们的源码之间的映射关系; - “**CFI(调用帧信息表)**”描述了如何虚拟地展开调用栈; - 考虑到DWARF需要针对**多种编程语言**和**不同的机器架构**表达许多不同的细微差别,因此Dwarf中也有很多微妙之处。 以gcc为例,通过选项-g “**gcc -g -c filename.c**” 能够生成DWARF调试信息并将其存储到目标文件filename.o的调试信息相关的section中。go程序构建时则是会默认生成调试信息。 ![img](assets/clip_image012.png) 通过使用 “**readelf -w**” 能够读取、显示所有生成的DWARF调试信息,也可以指定特定的section来加载特定的DWARF调试信息,如 “**readelf -wl**” 只加载 .debug_line 行号表信息。 ![img](assets/clip_image013.png) 本章已经详细介绍了DWARF对编程语言中不同程序构造的描述,个别的还提供了完整的示例并结合计算过程进行了详细讲解。希望读者已经搞明白了DWARF的大致工作原理。 本章先是介绍了调试信息标准DWARF如何描述程序的不同构造,包括数据结构、编译单元、函数、调用栈、行号表等等,然后我们又以go-delve/delve中DWARF解析相关的代码进行了一定的分析、应用,来帮助大家更好地认识DWARF的实际价值和用途。 尽管我们提供了一些go-delve/delve中DWARF相关的测试用例,但是和实现一个真正的符号级调试器而言,这也只能算是DWARF应用的一点皮毛而已。真正实现的时候,我们需要更加复杂的DWARF操作,甚至要对go的设计实现非常精通,我们才能实现一个真正可用的符号级调试器。 让我们带着储备的基础知识以及实现一个符号级调试器的“野心”,一起进入下一章吧。 ================================================ FILE: book/8-dwarf/README.md ================================================ ## 软件调试挑战 dwarf-logo 如果能编写出保证正确运行且无需调试的程序,那将是再好不过了。但至少在实现这一目标之前,正常的编程周期仍然包括编写程序、编译程序、执行程序,以及随之而来的、常常令人头疼的调试过程。然后反复迭代,直到程序达到预期效果。 ### 贯穿全生命周期 软件调试这项技术贯穿软件开发的全生命周期,不止是开发测试阶段,软件交付上线后也依然需要长期维护、迭代,本书在第10章详细介绍了软件开发全生命周期中的不同的问题排查技术,希望能启发读者朋友的思路,不同场景采用合适的手段(可能是单一手段,也可能是几种手段的组合) 可以事半功倍。OK,本章我们回归主题,聚焦在基于调试器进行软件调试上。 调试程序的一种方法是在代码中插入打印选定变量值的语句。在某些情况下,例如调试内核驱动程序,这可能是一种常用的方法。低级调试器允许您逐条指令地执行程序,并显示寄存器和内存内容的二进制信息。不过,使用源代码级调试器通常更为便捷,它允许您逐行执行程序代码、设置断点、打印变量值,并提供其他功能,例如在调试过程中调用程序中的函数。关键在于如何协调编译器和调试器这两个完全不同的程序,从而实现程序的调试。 ### 重建源码视角之难 将人类可读的代码编译成处理器能够执行的二进制形式,是一个相当复杂的过程。它本质上是将源代码逐步转换成越来越简单的形式,在每一步都丢弃信息,最终得到处理器能够理解的一系列简单指令、寄存器、内存地址和二进制值。 处理器其实并不关心您使用了面向对象编程、模板还是智能指针,它只理解对有限数量的寄存器和内存位置执行的非常基础的操作。 编译器在读取和解析源代码时,会收集关于程序的信息,例如变量或函数声明和使用的行号。 语义分析则在此基础上,补充变量类型和函数参数等细节。 优化阶段可能会重新排列代码结构、合并相似的代码片段、展开内联函数,或者删除冗余的部分。 最后,代码生成器将程序的内部表示转换成实际的机器指令。 为了进一步提升效率,通常还会对机器代码进行“窥孔”优化 (peephole optimization),这是一种局部优化技术,例如将几条指令改写成更高效的指令、消除重复指令等。 总的来说,编译器的任务是将精心编写且易于理解的源代码,转换成高效但本质上难以理解的机器语言。 编译器越能实现创建紧凑且快速代码的目标,结果就越可能难以理解。 在转换过程中,编译器会收集关于程序的信息,这些信息在后续调试时会非常有用。 这方面存在两个挑战。 一是,在转换过程的后期,编译器可能难以将它所做的更改与程序员最初编写的源代码联系起来。 例如,窥孔优化器可能会删除一条指令,因为它能够重新排列内联函数实例化中 C++ 模板代码的执行顺序。 当优化器对程序进行操作时,它可能难以将低级代码的操作与生成它的原始源代码联系起来。 二是,如何在足够详细地描述可执行程序及其与原始源代码的关系,同时保持描述的简洁性,避免占用过多空间或消耗过多处理器时间,这本身就是一个难题。DWARF 调试格式应运而生,它以一种相对高效的方式表示可执行程序与源代码之间的联系,方便调试器进行处理。 ### 软件调试过程 当开发人员对程序进行调试时,他们常常需要执行一些常见的操作。其中最常见的就是设置断点,以便在源代码的特定位置暂停调试,可以通过指定行号或函数名来实现。当断点触发时,程序员通常希望查看局部或全局变量的值,或者函数的参数。查看调用堆栈可以帮助程序员了解程序是如何到达断点的,尤其是在存在多个执行路径的情况下。在掌握这些信息后,程序员可以指示调试器继续测试程序的执行。 调试过程中还有一些其他有用的操作。例如,逐行跟踪程序的执行可能很有帮助,可以进入或跳过调用的函数。在模板或内联函数的每个实例处设置断点对于调试 C++ 程序至关重要。在函数即将返回之前停止,以便查看或修改返回值也可能很有帮助。有时,程序员可能需要绕过函数的执行,直接返回一个已知的值,而不是让函数(可能错误地)计算结果。 此外,一些与数据相关的操作也很有用。例如,直接显示变量的类型可以避免在源代码中查找。以不同的格式显示变量的值,或者以指定的格式显示内存或寄存器,都能提供便利。 有些操作可以被认为是高级调试功能,比如调试多线程程序或存储在只读内存中的程序。人们可能希望调试器(或其它程序分析工具)能够记录代码的哪些部分已经执行过。一些调试器还允许程序员调用正在测试程序的函数。过去,调试优化过的程序曾被视为一项高级功能。 调试器的目标是尽可能以自然、易于理解的方式向程序员呈现正在执行的程序,并提供广泛的控制权限。这意味着调试器需要尽可能还原编译器所做的复杂转换,将程序的内部状态转换回程序员最初编写的源代码所使用的形式。 像DWARF这样的调试数据格式的挑战就在于,要实现这种还原,并且还要让它变得简单易行。 ### 调试信息格式 软件调试领域,曾经出现了如下几种调试格式,但是都存在这样那样的问题,比如 stabs、COFF、PE-COFF、OMF、IEEE-695,而 DWARF 算是后起之秀吧。 “stabs” 这个名字源于符号表字符串,因为最初的调试数据以字符串的形式保存在 Unix a.out 对象文件符号表中。Stabs 使用文本字符串来编码程序的信息。它最初非常简单,但随着时间的推移,演变成一种相当复杂、有时难以理解且不太一致的调试格式。Stabs 既没有标准化,文档也比较匮乏。Sun Microsystems 对 stabs 进行了许多扩展,GCC 也进行了其他扩展,试图逆向工程 Sun 的扩展。尽管如此,stabs 仍然被广泛使用。 COFF 代表 Common Object File Format,起源于 Unix System V Release 3。在 COFF 格式中定义了基本的调试信息,但由于 COFF 支持命名部分,因此各种不同的调试格式,例如 stabs,都与 COFF 一起使用。COFF 的主要问题在于,尽管名称中包含“Common”,但它在不同的体系结构上并不完全一致。COFF 存在多种变体,包括 XCOFF(用于 IBM RS/6000)、ECOFF(用于 MIPS 和 Alpha)和 Windows PE-COFF。这些变体的文档可用性各不相同,但对象模块格式和调试信息均未标准化。 PE-COFF 是 Microsoft Windows 从 Windows 95 开始使用的对象模块格式。它基于 COFF 格式,包含 COFF 调试数据以及 Microsoft 自己的专有 CodeView 或 CV4 调试数据格式。关于调试格式的文档既不完整,又难以获取。 OMF 代表 Object Module Format,是 CP/M、DOS 和 OS/2 系统以及少量嵌入式系统使用的对象文件格式。OMF 定义了调试器使用的公共名称和行号信息,并且可以包含 Microsoft CV、IBM PM 或 AIX 格式的调试数据。OMF 仅提供对调试器的最基础的支持。 IEEE-695 是一种由 Microtec Research 和 HP 在 20 世纪 80 年代末联合开发的标准对象文件和调试格式,用于嵌入式环境。它于 1990 年成为 IEEE 标准。这是一种非常灵活的规范,旨在适用于几乎任何机器架构。调试格式采用块结构,更好地反映了源代码的组织方式。尽管它是一个 IEEE 标准,但在很多方面,它更像一种专有格式。虽然原始标准可以从 IEEE 获得,但 Microtec Research 对其进行了扩展以支持 C++ 和优化代码,这些扩展记录不详。IEEE 标准从未修改以纳入 Microtec Research 或其他更改。尽管它是一个 IEEE 标准,但它的使用仅限于少数小型处理器。 **DWARF** 是现在广泛使用的调试信息格式(尽管最初设计用于 ELF文件)。“DWARF”一词源于中世纪幻想小说,本身没有官方含义。后来,人们提出了“**Debugging With Attributed Record Formats**”作为 DWARF 调试信息的另一种定义。DWARF 使用 **DIE + Attributes** 来描述类型和数据、代码等程序构造。DWARF 还定义了 **行号表 (Line Number Table)** 和 **调用栈信息表 (Call Frame Information)**等数据,这些使得开发者能够在源码视角动态设置断点、显示当前 PC 对应的源码位置、跟踪调用栈信息。 ### 本节小结 本文简要介绍了软件调试在软件开发全生命周期中的必要性、重要性,也介绍了源码到可执行程序的转换过程中存在一些刻意去除的信息,从可执行程序还原到源码视角存在比较大的困难和挑战,然后列举了常见的调试信息格式的问题,它们都曾经致力于实现源码视角的重建。其中的 DWARF 标准包含许多精妙的设计,已经是当前使用最广泛的调试信息格式,比如C、C++、Go等都使用的DWARF。如果你对高级语言的符号级调试感兴趣,建议学习本章内容。 ### 参考文献 1. DWARF, https://en.wikipedia.org/wiki/DWARF 2. DWARFv1, https://dwarfstd.org/doc/dwarf_1_1_0.pdf 3. DWARFv2, https://dwarfstd.org/doc/dwarf-2.0.0.pdf 4. DWARFv3, https://dwarfstd.org/doc/Dwarf3.pdf 5. DWARFv4, https://dwarfstd.org/doc/DWARF4.pdf 6. DWARFv5, https://dwarfstd.org/doc/DWARF5.pdf 7. DWARFv6 draft, https://dwarfstd.org/languages-v6.html 8. Introduction to the DWARF Debugging Format, https://dwarfstd.org/doc/Debugging-using-DWARF-2012.pdf ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/1-现代调试器架构.md ================================================ ## 软件调试的挑战与现代调试器架构设计 ### 软件调试在真实环境中的挑战 在现代软件开发与运维过程中,调试器作为定位和解决问题的核心工具,面临着诸多挑战: - **多平台兼容性**:应用程序需要在不同操作系统(如Linux、macOS、Windows)和多种硬件架构(如amd64、arm64)上运行,调试器需具备良好的跨平台能力。 - **远程与分布式调试**:随着云原生、微服务架构的普及,调试目标进程往往运行在远程主机、容器或沙箱环境中,传统本地调试方式难以胜任。 - **安全与隔离性**:生产环境对调试操作有严格的安全隔离要求,调试器需支持最小权限原则,避免对业务系统造成影响。 - **高性能与低侵入性**:调试器需尽量减少对被调试进程(tracee)的性能影响,尤其是在高并发、低延迟场景下。 - **丰富的调试功能**:包括断点、单步、变量查看、内存检查、线程/协程切换、调用栈分析等,且可能需要支持多语言和多运行时(如gdb就支持多种语言的调试能力)。 - **制品及源码管理**:线上程序表现出症状需要调试时,如何快速确定源码版本、检出源码远程调试又如何解决检出源码路径与制品构建时源码路径不一致问题。 - **偶现及确定性调试**:如果问题可以稳定复现,离力解并解决也就不远了,现实中存在非常多难以复现的问题,如何进行复现并实现确定性调试是一大挑战。 - **其他挑战**:也许读者朋友们也有自己的痛点,而作者并没有一一列出,这才是真实的、复杂的计算机世界。 为了更好地应对上述挑战,调试器的架构也在迭代、升级,一起来学习下现代调试器gdb、dlv等的整体架构。 注:本章tinydbg设计实现是在 `go-delve/delve@v1.24.1` 基础上裁剪而来,感谢 derekparker、aarzilli以及诸多贡献者的无私奉献,如果没有他们的开源精神,我的好奇心也不会得到这么大程度地满足,也不会有本章内容。为了避免裁剪后的dlv构建、安装覆盖读者们已安装的go-delve/delve,作者刻意将fork、裁剪后的代码库module名进行了修改,将 `module github.com/go-delve/delve` 修改为 `module github.com/hitzhangjie/tinydbg`。后文我们不会再反复强调tinydbg源自于go-delve/delve,但如果你后续对比二者,你会发现tinydbg基本保留了原来的代码结构,只是对linux/amd64以及一些与教学无关简要的内容进行了裁剪。 ### 现代调试器架构设计 为了更好地解决现实中软件调试面临的的各种挑战,现代现代调试器一般采用前后端分离式架构,并且在UI层、服务层、符号层、目标层支持可扩展,如下图所示:

debugger arch

- **前后端解耦**:将UI/交互层(frontend)与核心调试逻辑(backend)分离,二者通过标准协议通信。这样可以灵活适配不同的前端(CLI、GUI、Web、IDE插件等),也便于后端独立演进和扩展。 - **多种通信模式**:支持本地模式(如 `pipe`实现的进程内通信)和远程模式(如基于 `JSON-RPC`、`DAP`的网络通信),满足本地和远程调试需求。 - **跨平台支持**:后端核心调试能力通过接口抽象和条件编译,适配多种操作系统(windows/linux/macOS)和硬件架构(amd64/arm64/powerpc),多种文件格式(elf/macho/pe),多种调试信格式(DWARF/Stabs/COFF)。 - **安全与隔离**:远程调试时可通过权限控制、认证机制,保障生产环境安全。如只允许backend拥有部分PTRACE操作权限,而不是root权限,再比如可以对frontend调试进行用户认证、鉴权。 - **高性能优化**:精简核心功能,减少依赖,降低对被调试进程的侵入性。如线上环境可以限制只允许ebpf-based tracing操作来跟踪函数的耗时统计,而不允许断点等操作。 > 在PCG内容中台进行内容处理调度系统的设计实现时,我已经被前人们设计的系统折腾的要吐血了,那一刻我深刻地理解了这句话:"软件架构设计的核心目标之一,就是让不可见的东西变得可见",合理的架构设计,将子系统的职能边界清晰地划分开来,彼此之间通过约定好的协议进行通信,整个系统的能力就体现在子系统划分、子系统协议中。而不是傻大黑粗揉成一团,谁知道一个芝麻团里面有几个芝麻? 现代调试器基本都已经演进到上述架构,如gdb、dlv等,得益于这样的架构设计,使得现代调试器具备非常好的灵活性、适应性,基本可以解决我们前面提及的各种困难。 ### 前后端分离式架构 调试器的功能,主要包括UI层与用户的交互、符号层的解析、目标层对被调试进程的控制这3大部分组成。大家可能对本地调试都比较熟悉,如通过gdb、lldb、dlv或者IDE自带调试器对本地程序进行调试,本地调试场景下,UI交互、符号解析、进程控制都在同一个调试器进程中完成就可以了。什么情况下,我们不得不将其拆分成前端、后端两个调试器实例呢? #### 安全策略限制开发者登录机器 在一些企业中,由于安全策略的要求,开发人员可能无法直接登录到测试环境或生产环境的服务器上。这种情况下,如果需要调试运行在这些环境中的程序,传统的本地调试方式就无法满足需求。主要存在以下几个问题: - **访问限制**:开发人员没有服务器的登录权限,无法直接在服务器上启动调试器 - **权限隔离**:即使通过跳板机等方式获得了有限访问权限,也可能缺乏必要的调试权限(如ptrace权限) - **安全审计**:企业需要对调试操作进行严格的审计,记录谁在什么时间对哪些进程进行了调试 前后端分离的调试器架构为解决这些问题提供了可能: 1. 后端调试器可以由运维人员或自动化系统在目标服务器上启动,仅开放必要的调试端口 2. 前端调试器运行在开发人员的本地机器上,通过网络协议与后端通信 3. 在通信层面可以增加认证、授权、审计等安全机制 4. 调试操作可以通过统一的运维平台进行管理和控制 这种架构既保证了企业的安全要求,又为开发人员提供了远程调试的能力。 #### 被调试进程所在主机没有源码 真实场景下,其实有很大概率存在这样的调试问题。当然跟企业、项目有关: 1. 有的在开发者个人的开发环境即可进行测试,不存在没有源码的问题; 2. 有的需要在统一的测试环境测试,**测试环境没有源码**,但是测试环境管理往往比较松,开发可以 `rz` 上传源码; 3. 线上环境的管理往往是比较规范严格的,**线上环境没有源码**,开发人员通过运维系统更新服务、配置,在隔离流量、保留现场后是可以用来进行调试的,但是不允许 `rz` 上传源码; 4. 即使测试环境、线上环境可以上传源码,**源码版本要一致**、**上传需要时间**,再者上传的**源码路径与构建时的源码路径可能不一致**,且**没有root权限时可能无法解决**; 5. **开放root权限则安全性无从谈起**。 6. 如果不能上传源码,`sz` 下载二进制到本地调试总可以吧?可以下载,但是目标程序可能时linux/amd64,或者linux/arm64,who knows? 而你本地可能是windows、macOS。 7. 即使下载下来,本地机器与服务器也一致(或者手动找一台这样的机器),现场也丢了,对于一些flaky tests问题,没了现场很难以定位。 最终,就会造成这样的窘境,给调试带来了挑战: * 待调试的进程,它运行在另一台机器Host-2上,而我现在的机器是Host-1; * 但是Host-2上没有源代码; * Host-2上有现场,下载会本地Host-1程序不一定兼容,且丢失现场; 在调试器前后端分离架构下,这个问题就比较好解决。利用调试器前端所在主机上的源码进行调试,无需上传源码到被调试进程所在主机,只需在前端询问用户源码路径的映射关系即可,如将 `/path-to/main.go` 映射为构建时的路径 `/devops/workspace/p-{pipelineid}/src/main.go`。see: [https://github.com/go-delve/delve/discussions/3017](https://github.com/go-delve/delve/discussions/3017)。 #### CLI/GUI调试萝卜青菜各有所爱 有的开发人员调试倾向于使用跨平台一致的CLI调试界面,而有的开发人员倾向于使用VSCode进行调试,还有的开发人员倾向于使用Goland进行调试。可能不只是一种倾向的问题,而是不同开发人员开发习惯不同,使用的开发工具链也不同,如果我们的调试只支持CLI调试界面,或者只支持GUI调试界面,那就很不友好,会降低开发人员的调试效率。 前后端分离式架构下,这个问题就比较容易解决,以对go程序进行调试为例: - CLI调试界面,如dlv frontend可以通过JSON-RPC与dlv backend进行通信,完成对目标进程调试; - VSCode调试功能,如可以通过DAP(Debugger Adapter Protocol)与dlv backend进行通信,完成对目标进程调试; 由于是前后端分离式架构,我们可以独立开发新的调试器UI来更方便地调试: - 比如dlv是CLI调试界面,我们可以开发一个[aarzilli/gdlv](https://github.com/aarzilli/gdlv)。 #### 当前机器与目标机器os/arch不同 前面我们提到了远程机器上没有源码的问题,同时也提到了一个相关的问题 - 当前机器与目标机器的操作系统或硬件架构不同的情况。这种情况在实际开发和调试中非常常见: - 开发人员使用MacOS或Windows进行开发,但需要调试运行在Linux服务器上的程序 - 开发机器是x86_64架构,但需要调试运行在ARM架构服务器上的程序 - 在容器化环境中,容器内外的操作系统和架构可能不同 这些差异会带来以下挑战: 1. 本地编译的调试器可能无法在目标机器上运行 2. 调试器需要理解不同平台的可执行文件格式(如ELF、PE、Mach-O) 3. 调试相关的系统调用在不同平台上可能完全不同 4. 寄存器、内存布局等底层细节存在差异 前后端分离架构为解决这些问题提供了优雅的方案: - 后端调试器可以针对目标平台单独编译和部署 - 前端调试器只需关注用户交互,不需要关心平台差异 - 通过标准化的通信协议屏蔽平台细节 - 可以在同一个前端界面下调试不同平台的程序 #### 总结 前面讨论的几个问题 - 安全合规、源码访问、UI偏好差异、平台差异 - 正是促使我们采用前后端分离架构的主要原因。这种架构设计可以优雅地将调试器的用户界面逻辑与底层平台实现解耦,从而更好地应对这些挑战。 结合架构设计,我们将调试器(Debugger)拆分为Frontend和Backend两个核心组件: - Frontend负责所有与用户交互相关的功能,包括接收用户调试命令、展示调试结果、管理调试会话等。它专注于提供流畅的用户体验,而不需要关心底层实现细节; - Backend则负责在不同平台上实现具体的调试功能。以Linux/amd64平台为例,它需要解析ELF文件中的DWARF调试信息,通过系统调用控制被调试进程,并收集必要的运行时信息; - Frontend和Backend之间通过标准化的通信协议进行交互。Frontend将用户的调试指令转换为Backend可以理解的命令,Backend执行这些命令并返回结果,Frontend再将这些结果格式化后呈现给用户。 这种分离式架构不仅解决了前述问题,还为未来的扩展和改进提供了良好的基础。我们可以独立地改进前端界面或增加新的后端平台支持,而不会相互影响。 ### 通信模式 调试器的前后端分离式架构离不开前、后端之间的通讯,那这里的通讯应该考虑些什么呢? #### 不同进程:JSON-RPC over network 如果前后端运行在不同的机器,那么没什么可选择的,只能通过网络通信。在go标准库中提供了json-rpc的能力,我们可以借助go标准库轻松实现前、后端的通信。 一旦我们实现了前后端json-rpc通信的能力,其实如果前后端运行在相同主机上的问题,也可以解决了,只不过这个时候的网络地址变成了本地回环地址localhost/lo而已。 针对运行在相同主机这种情况,我们还需要考虑地更细致些,对使用方更友好一些,设计上更优雅一些。 ps: 当然在同一台机器上,如果是调试器前后端两个进程实例通信,除了TCP通信,也可以选择Unix通信。 #### IDE集成:DAP over network 调试器要集成到IDE中,就需要遵循IDE的调试适配器协议(Debug Adapter Protocol, DAP)。DAP是一个标准化的协议,定义了IDE和调试器之间的通信格式和流程。 DAP采用基于JSON的消息格式,通过TCP网络传输数据。虽然消息格式类似JSON-RPC,但DAP定义了自己的消息结构,包含了sequence、type等专用字段。它定义了一系列标准的请求和响应消息,包括: - 启动/附加调试会话 - 设置/删除断点 - 单步执行/继续运行 - 查看变量/调用栈 - 表达式求值 调试器实现了DAP协议,我们的调试器就可以无缝集成到支持DAP的IDE中,如VS Code、GoLand等。这样用户就可以在熟悉的IDE环境中使用我们的调试器,而不需要切换到专门的调试器界面。 另外,如果IDE中一个插件实现了对某个编程语言的基于DAP的调试,也意味着它可以在不同的调试器backend之间切换,如go语言调试时,VSCode可以从调试器实现dlv切换到gdb等。 #### 同进程内:ListenerPipe 如果前后端都运行在本机,那似乎直接UI层、符号层、目标层就足够了,这种形式下用户调试动作转换为目标层进程控制就是简单的上层调用下层封装的函数。但是我们已经拆分成前后端分离式架构了,且明确了前、后端之间要借助service通讯来完成交互。如果仅仅是因为本机,就直接绕过service层对target层进行函数调用,会让各层之间的边界不清不楚、不优雅、徒增复杂性。 那我们是不是可以选择json-rpc,完全通过网络方式来进行呢?我们可以和操作系统申请一个端口并用这个端口来完成通信,以避免固定一个端口与其他调试器实例或者其他本机进程出现端口占用冲突。 这个方案是一个可行的方案,但是我们再仔细斟酌下: - 在同一台机器上运行两个前端、后端两个调试器实例,它们之间通过json-rpc通信,但是这种多进程架构、本机却还通过网络通信,这种不够优雅。以Linux为例,为什么父子进程不借助pipe、fifo、共享内存等高效率的通信方式呢? - 假设仍采用前后端两个调试器实例,首次启动的调试器实例为父进程,它应该作为前端进程,它还需要启动一个子进程,然后在二者之间建立一个pipe用来完成进程间通信?这种多进程+pipe的方式多出现在c/c++单进程单线程程序中,而go本身是协程并发,直接一个进程+pipe就可以搞定类似的功能?而且标准库确实也提供了net.Pipe来返回这样的pipe供goroutines间进行通信。 - 搞明白这些,现在要考虑下如何对service层的通信进行设计。service层涉及到的无非是网络通信,前端涉及到的是net.Dial(...)建立连接net.Conn,而后端涉及到的是net.Listen(...)获取listener并通过listener.Accept(...)获取新建立的连接net.Conn,然后frontend、backend通过各自建立的net.Conn进行通信。 对于json-rpc而言本身就是走的go的网络库,这些操作自然是没问题的。但是如果我们想在同一个进程里面让前后端的service层通过net.Pipe通信,还要向网络通信接口看齐,那我们可以自定义实现net.Listener,如ListenerPipe,它内部包括了一个net.Pipe,后端通过ListenerPipe.Accept返回一个net.Conn实现(本质是net.Pipe的一端),而前端也可以拿到与之关联的net.Conn实现(本质也是net.Pipe的一端)与后端进行通信。 #### 总结 这样我们就实现了调试器在相同主机、跨不同主机下运行的service层通信问题,当跨不同主机时,可以用json-rpc完成通信,当在相同主机时也可以用json-rpc通信,也可以用单进程+ListenerPipe来实现。而在与IDE集成方面,我们需要实现DAP协议。 后面我们会讲述调试器运行时如何决定自己运行在前后端单一进程模式下,还是前后端分离模式下。 ### 平台可扩展性 讲述前后端分离式架构时,我们提到了前端、后端可能运行在不同类型的主机上,这些主机的操作系统、硬件架构可能都有明显的差异。这些差异性可能会导致我们的调试器在一种操作系统、硬件平台组合下运行良好,但是在另一种组合下可能会直接crash或者根本无法运行。 调试动作相对来说是可枚举的,如设置断点、读写内存、读写寄存器、单步执行等,我们需要将其转换为目标层的操作集合,而在不同操作系统、硬件平台实现这些目标层的操作时,就需要考虑不同平台的差异性。 这里就需要对目标层的操作集合进行必要的抽象,如提炼出一个Target interface{},它包含了对目标进程所有的操作,然后不同的操作系统、硬件平台提供对应的Target interface{}的实现。 ### 调试对象扩展性 我们要调试的可能是一个运行中的进程,也可能是一个已经消亡的进程生成的core文件(也习惯称coredump文件)。 运行中的进程,只要它还在运行期间,你几乎可以通过操作系统拿到它所有的状态信息,但是一旦它挂掉了仅仅通过其挂掉前生成的core文件是不可能还原出进程运行时所有的状态信息的。core文件中通常只记录了进程挂掉之前的调用栈信息,以方便开发人员了解程序最终在这里出现了致命的、不可恢复的错误。 我们前面提到Target interface{}是对目标进程进行控制,这里当然也就少不了对进程状态的读写,这里就需要考虑对真是的进程和进程core文件状态读写的差异性,就需要考虑提炼出一个Process interface{},而进程、core文件提供对应的实现。 ### 文件可扩展性 不同操作系统上生成的可执行文件、core文件的格式是有差异的,如Linux多是采用ELF,Darwin多是采用Macho,Windows是采用PE,而对于core文件呢,Linux是采用的ELF,Windows是采用的PE,Darwin不详。 这些文件格式的差异,注定了我们在读取文件时、读取调试信息时会存在一定的差异性,比如: 它们的文件头都不一样的; - 调试信息存储的section名可能也不一样,如有的开启了zlib压缩放在了.zdebug_ sections下,有的没压缩放在了.debug_ sections下; - 甚至它们都不将调试信息数据放在二进制程序文件中,如Darwin可能将调试信息放在与二进制程序同级的.dSYM目录下; - 更有甚者它们都不一定使用DWARF调试信息格式; 因此,对可执行文件的描述需要进行适当的抽象,以屏蔽不同平台可执行文件的差异。 ### 调试信息格式可扩展 调试信息格式也是可能不同的,DWARF是后起之秀,且采用DWARF来作为调试信息格式的语言、工具链越来越多,比如go工具链就是采用DWARF作为其调试信息格式。 因为本书主要是介绍go符号级调试器的设计实现,而go编译工具链本身也是采用的DWARF,所以我们本来没有必要提及调试信息的可扩展性。但是谁也不能保证后续会不会出现一个描述性更强、效率更高、占用空间更少的调试信息标准,即便不会出现,DWARF本身也是一个不断演进中的标准,从其广泛接受的版本v4到现如今的v5,也还是有些差异的,那当我们调试携带有不同版本DWARF数据的二进制程序时,也要面临这个差异性的问题。 为此我们可以考虑在调试信息格式的加载、读取、解析时进行一定的抽象,从而屏蔽DWARF不同版本、甚至是不同调试格式的差异。 DWARF作为后期之秀,其前辈们(如Stabs、COFF、PE/Coff、OMF、IEEE-695等)是不可能再战胜它了,如果读者对这些过去曾名噪一时的标准感兴趣,可以参考:Debugging Information Format。 ### 调试器backend可扩展 调试器实现了前后端分离式架构之后,也给了我们更大的灵活性。我们自己的调试器实现需要分离成frontend、backend两部分,那么其他调试器gdb、lldb、mozilla rr是否也可以作为我的debugger frontend的backend呢?也是可以的。 为什么会有这样的诉求呢? - 假设我们实现的调试器后端部分缺少一个功能,比如dlv没有ptype打印类型信息的能力,但是gdb有这个能力,那我能不能用dlv的前端连接gdb的后端来实现ptype功能呢? - 再比如,我现在想实现反向调试功能,但是dlv没有这个能力,但我知道mozilla rr(record and play)可以实现反向调试,那我能不能用dlv的前端连接rr实现反向调试功能呢? 为了能够让我们的后端支持dlv backend、gdb backend、rr,我们也可以进行必要的抽象设计,这样当我们调试时可以指定--backend参数来启动不同的backend实现。 首先要明确的是,我们tinydbg debugger frontend提供的调试能力,是适用于所有backend实现的(包括tinydbg backend、gdb、lldb、mozilla rr)。由于debugger frontend只负责UI层交互与展示,因此当我们希望切换不同的debugger backend时,我们需要debugger frontend通过请求参数的形式告知debugger backend,而debugger backend这里根据--backend来选择对应的实现,如native(tinydbg),gdb(gdbserial访问gdbserver),lldb,rr(gdbserial访问mozillar rr)。 ### 本节小结 本文详细探讨了调试器设计中的关键可扩展性问题。我们从多个维度分析了调试器需要考虑的扩展性,包括: - 调试动作的抽象与目标层操作的扩展性 - 调试对象(进程与core文件)的扩展性 - 不同操作系统下可执行文件格式的扩展性 - 调试信息格式(如DWARF及其版本)的扩展性 - 调试器backend的可扩展性 通过前后端分离的架构设计,以及在各个层面的合理抽象,gdb、dlv等现代调试器都实现了良好的可扩展性。这使得它能够适应不同的使用场景,包括: - 本地开发环境下的日常调试 - 远程服务器或容器环境中的调试 - 在多平台CI/CD流程中进行自动化调试 - 在生产环境中安全地进行问题诊断 这种可扩展的设计不仅提升了调试器的适应性,也为未来功能的扩展和优化提供了良好的基础。tinydbg设计实现后续内容也会在这些方面进行介绍。 ### 参考文献 - go-delve/delve, https://github.com/go-delve/delve - gdb, https://sourceware.org/gdb/ - mozilla rr, https://rr-project.org/ - dap, https://microsoft.github.io/debug-adapter-protocol/ ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/2-前端UI层设计.md ================================================ ## 前端UI层设计 arch 联想下调试器的整体架构设计,前后端分离式架构中,前端部分主要包括两部分: - UI层为用户提供调试相关的界面交互逻辑; - Service层完成与调试器后端实现的通信,完成对被调试进程的各种控制; 前端、后端的Service层设计统一在《Service层设计》小节进行描述。本节来介绍下前端UI层的详细设计,以及相关的技术点。 ### 图形化调试界面 对于图形化的调试界面,包括: - 将终端从文本模式调整为图形模式,以可视化的方式进行调试,这类支持库包括 ncurses 等; - 使用图形库设计实现的图形化调试界面,如 gdlv 基于 nuklear图形库实现; - 在IDE中实现调试插件,如VSCode中自带的或者第三方的调试插件,使用JS或者TS实现调试界面; 图形化调试界面的内容不在我们的详细讨论范围内,我们只是罗列下,这是一个可以扩展的方向。 图形化界面调试,相比于终端中文本模式的命令行界面调试,有着非常大的优势,它可以一次性展示更多内容。命令行调试界面要支持的操作,图形化界面下肯定要都应该支持,但是图形化界面可以同时展示的东西更多,理论上UI层的设计上也会需要更细腻。 ### 命令行调试界面 我们本章要实现的Go调试器,最终形态是一个在终端文本模式下的命令行调试器,以文本模式的形式与用户交互,获取用户输入的调试命令,转换成对应的调试动作执行,并将结果以文本模式的形式显示出来。 > 终端可以工作在文本模式,或者图形模式下,我们这里采用文本模式。其实主流的命令行调试器gdb、lldb、dlv等都是工作在终端文本模式下。 命令行调试相比图形化调试有其独特的优缺点: **优势:** 1. 跨平台一致性:文本模式调试界面在不同操作系统上表现一致,不需要为不同平台开发特定的图形界面 2. 资源占用少:不需要加载图形库,对系统资源要求更低 3. 远程调试友好:在远程服务器或容器环境中,文本模式更容易通过SSH等远程连接使用 4. 可脚本化:命令行操作更容易被脚本化,便于自动化调试流程 5. 学习曲线统一:一旦掌握命令行调试,可以快速适应不同的命令行调试工具 **劣势:** 1. 信息展示受限:一次只能展示有限的信息,需要频繁切换视图 2. 命令记忆负担:需要开发者熟记各种调试命令及其参数 3. 操作效率:输入命令通常比点击图形界面按钮更耗时 4. 可视化效果差:难以直观地展示复杂的数据结构或调用关系 5. 新手友好度低:对初学者来说,命令行界面可能显得不够直观和友好 ### 调试命令支持 go符号级调试器的功能性需求,大家联想下常见调试器的使用经历,这个是比较直观的: #### 启动调试支持多种调试对象类型 | 命令 | 描述 | | ---------------------- | --------------------------------------------------------------------- | | godbg attach | 调试一个正在运行中的process | | godbg exec | 启动并调试指定的go executable | | godbg test | 调试当前go package中的测试函数 | | godbg debug | 调试当前go main module | | godbg debug --headless | 类似gdbserver的headless模式 | | godbg dap | 启动一个headless模式的服务,接收DAP协议请求,以方便与VSCode等进行集成 | | godbg core | 启动并调试指定的coredump | | godbg tracepoint | 支持tracepoint能方便观察程序执行命中的函数 | #### 调试会话支持多种调试命令 **1 Running the program** | 命令 | 别名 | 描述 | | ---------------- | ---- | :-------------------------------------------------------------------------------------------------------- | | call | - | Resumes process, injecting a function call (EXPERIMENTAL!!!) | | continue | c | Run until breakpoint or program termination. | | next | n | Step over to next source line. | | rebuild | - | Rebuild the target executable and restarts it. It does not work if the executable was not built by delve. | | restart | r | Restart process. | | step | s | Single step through program. | | step-instruction | si | Single step a single cpu instruction. | | stepout | so | Step out of the current function. | | rr相关 | | rr相关的命令,如rnext, rstep... | **2 Manipulating breakpoints** | 命令 | 别名 | 描述 | | ----------- | ---- | -------------------------------------------- | | break | b | Sets a breakpoint. | | breakpoints | bp | Print out info for active breakpoints. | | clear | | Deletes breakpoint. | | clearall | | Deletes multiple breakpoints. | | condition | cond | Set breakpoint condition. | | on | | Executes a command when a breakpoint is hit. | | toggle | | Toggles on or off a breakpoint. | | trace | t | Set tracepoint. | **3 Viewing program variables and memory** | 命令 | 别名 | 描述 | | ---------- | ---- | ---------------------------------------- | | args | | Print function arguments. | | display | | Disassembler. | | examinemem | x | Examine raw memory at the given address. | | locals | | Print local variables. | | print | p | Evaluate an expression. | | regs | | Print contents of CPU registers. | | set | | Changes the value of a variable. | | vars | | Print package variables. | | whatis | | Prints type of an expression. | **4 Listing and switching between threads and goroutines** | 命令 | 别名 | 描述 | | ---------- | ---- | --------------------------------------- | | goroutine | gr | Shows or changes current goroutine | | goroutines | grs | List program goroutines. | | thread | tr | Switch to the specified thread. | | threads | | Print out info for every traced thread. | **5 Viewing the call stack and selecting frames** | 命令 | 别名 | 描述 | | -------- | ---- | --------------------------------------------------------------- | | deferred | | Executes command in the context of a deferred call. | | down | | Move the current frame down. | | frame | | Set the current frame, or execute command on a different frame. | | stack | bt | Print stack trace. | | up | | Move the current frame up. | **6 Other commands** | 命令 | 别名 | 描述 | | ----------- | -------- | ------------------------------------------------------ | | config | | Changes configuration parameters. | | disassemble | disass | Disassembler. | | dump | | Creates a core dump from the current process state | | edit | ed | Open where you are in `$DELVE_EDITOR` or `$EDITOR` | | exit | quit / q | Exit the debugger. | | funcs | | Print list of functions. | | help | h | Prints the help message. | | libraries | | List loaded dynamic libraries | | list | ls / l | Show source code. | | source | | Executes a file containing a list of delve commands | | sources | | Print list of source files. | | types | | Print list of types. | | ptype | | Print type info of specific datatype. | 使用过Go调试器 `go-delve/delve` 的读者,对上述列出的调试命令应该不陌生,我们基本上是罗列了 `go-delve/delve` 中支持的调试命令,额外增加了一个受gdb启发的 `ptype` 打印类型详情的命令。 > dlv支持 `whatis ` 来查看expr对应的类型信息,但是如果我们定义了一个类型、类型上定义了一些成员、方法,whatis只能输出类型名,而不能输出成员、方法,这个很不方便。 > > 而gdb `ptype` 就支持,下面是个gdb的示例,我们将在后面的实现阶段,实现和 gdb ptype 一样的效果。 > > ```bash > (gdb) ptype student1 > type = class Student { > private: > std::__cxx11::string name; > int age; > > public: > Student(std::__cxx11::string, int); > std::__cxx11::string String(void) const; > } > ``` 写这本书的初衷是为了解释如何开发一款符号级调试器,而非为了写而写,更不是为了超越dlv。考虑到调试功能完整度、相关知识的覆盖度、工程的复杂度、个人时间有限等诸多因素,我们将fork go-delve/delve实现,并进行适当的裁剪,保留核心设计、删减与linux/amd64无关架构扩展代码、删减dap实现、删减对接不同调试器backend gdb、lldb、rr的扩展,这些代码中被移除但是又有必要介绍的内容,将其迁移至扩展阅读部分介绍。 ### 调试命令管理 需要支持的调试功能众多,我们前面做需求分析时对需要支持的调试命令进行了整理,并将它们按照调试动作的类型进行了分组。 这些要支持的调试命令,根据使用的阶段可以分成两类。一类属于如何发起调试,一类属于在调试会话中如何读写、控制被调试进程状态。这样的话,我们在进行命令管理的时候就要注意区分为两组不同的命令。 #### 方式1:统一由cobra管理 在进行指令级调试器设计实现时,我们采用cobra命令行框架来组织命令。首先我们注册了两个发起调试的命令:`godbg exec`和 `godbg attach`。 ```go rootCmd.AddCommand(execCmd) rootCmd.AddCommand(attachCmd) ``` 当调试器正常attach到被调试进程后,我们会紧接着启动一个调试会话DebugSession,其实这个DebugSession内部能运行的所有调试命令,也是由cobra命令行框架管理的,每个调试会话内部都有一个 `root *cobra.Command`,我们在这个root上注册了一系列调试命令。 ```go // DebugSession 调试会话 type DebugSession struct { root *cobra.Command ... } debugRootCmd.AddCommand(breakCmd) debugRootCmd.AddCommand(clearCmd) ... debugrootCmd.AddCommand(nextCmd) ``` 启动调试的命令、调试会话中的调试命令,这些命令我们都是用cobra来管理的,只不过分了两级来管理,这种设计方式更优雅简单。 #### 方式2:cobra+自定义管理逻辑 是接下来我们要换一种实现思路,启动调试的命令attach、exec等还是采用cobra管理,调试会话中的调试命令将用自己编写的命令组织逻辑来管理。为什么要这么做呢? - 需要允许用户自定义调试命令的别名,而不仅仅是 `cobra.Command.Aliases`中指定的这些,而cobra也没有提供可配置的方式来自由添加别名; - cobra框架中各个命令对应的处理函数只有cmd、flags、args参数,但是调试过程中我们需要维护一点状态相关的信息,并且需要将这些信息传递给调试命令的处理函数,当然是以参数的形式,而cobra框架中命令对应的处理函数的列表是无法传递额外参数的,而这些也不适合通过共享变量的形式来维护; - 除了要实现的这些功能,最终也希望能提供额外的扩展能力,我们可以为调试器嵌入starlark脚本引擎、注册新调试命令的函数,这样开发人员可以自定义starlark函数作为调试器的新的调试命令,这样来扩充调试器功能。要实现这些这就要求调试器实现能够对子命令的管理逻辑细节100%可控制,而cobra作为一个命令行管理框架存在一些限制; 因此,在接下来的符号级调试器实现中,调试会话中的调试命令是通过重写的命令管理逻辑来完成的,而非像之前那样由cobra管理(调试器 go-delve/delve 也是这么做的)。 ### 用户交互设计 这里与用户的交互,主要涉及到用户的输入、调试器的输出两部分。 #### 用户输入 当执行attach或exec启动调试之后,会启动一个调试会话,其实就是一个可以输入调试命令、展示调试结果的命令行窗口: - 用户可以在stdin输入调试命令及其参数,然后等待调试器执行对应的调试动作(如读写内存),然后等待调试器结果,结果会输出到stdout; - 用户可以输入 `help`命令查看当前调试器支持哪些调试命令,这些命令将按照所属的分组进行汇总显示,如断点相关、运行暂停相关、数据读写相关、goroutine相关、stack等分组; - 用户也可以输入 `help subcmd`来显示某个特定命令的详细帮助信息,此时会显示subcmd的各个参数的帮助信息; - 用户可以输入调试命令的别名,而非完整的命令名,以简化命令输入; - 用户可以直接键入回车键Enter,来重复执行上一次输入的调试命令,这在执行next、step时将非常有用; - 为了方便用户输入过去输入过的调试命令,我们还可以记录用户输入过的命令,并允许用户通过方向键up/down来选择过去输入过的命令,并且还可以允许自动补全,以简化命令输入; - 当用户向结束调试时,可以通过ctrl+c或者exit、quit等命令结束调试; 用户的输入动作都是非常简单的在stdin上的行输入,在调试会话启动后,我们就可以启动一个for-loop来不停地读取stdin上的行输入,当读取到一个完整的行之后,我们就将输入信息进行解析,解析成命令、及参数,这里的命令也可能是别名。然后查找所有的命令中哪个命令的别名与用户输入相同,一旦找到该命令,则执行命令关联的处理函数,完成调试动作。 关于这里的输入逻辑,接下来将使用[peterh/liner](https://github.com/peterh/liner)这个第三方库来方便地管理用户输入、执行输入处理、记录历史输入、输入自动补全等功能。 #### 调试器输出 调试器的输出信息,包括执行日志,以及调试命令的结果。这两类信息,我们的调试器实现中都是将其输出到stdout,以简化实现复杂度。 - 本地调试时,调试器前端、后端的日志都是输出到stdout的,调试结果首先是由backend发送给frontend,frontend做些数据转换之后就输出到stdout显示出来。所以本地调试时,日志、调试结果都可以在stdout中查看到; - 远程调试时(或者是同一个机器也是起了前端、后端两个进程时),调试器前端、后端的日志各自输出也均输出到stdout,如果是在两个不同的终端中运行,那么日志输出到对应的终端中。对于调试结果则由backend发送给frontend,最终由frontend显示在其对应的终端中。 - 对于frontend、backend对应的日志如果不关心,可以通过日志级别将其关闭,或者通过选项--log指定个日志文件让其将日志信息输出到指定日志文件中。 > ps:支持--log选项,这么设计并不一定最终这么实现,我们为了赶进度,做了些简化,只允许调试日志、结果输出到stdout,但是会给予一定的日志级别控制。 > - 个别输出信息可能需要颜色高亮,如执行 `l main.go:10`这样来查看源代码时,我们希望能根据源代码中不同的关键字、语句、注释、字符串、当前执行到的源码行等能像IDE中那样有个不同颜色的高亮显示,这样对于用户而言无意是更加友好的。这就意味着我们需要对源代码进行必要的AST分析统计出有哪些词素需要高亮显示。 #### 输入输出重定向 对于被调试进程而言,它可能需要从stdin读取输入,向stdout、stderr输出信息,但是调试器进程本身也存在类似的需要。 这样就产生了读写冲突,问题来了: - 当用户在stdin输入时,究竟是将输入内容给调试器呢,还是给被调试进程呢? - 当在stdout、stderr有输出时,输出信息时来自调试器呢,还是来自被调试进程呢? 为了解决这个问题,我们需要为被调试进程提供输入、输出重定向的能力,比如 `godbg exec ./main -r stdin:/path-to/fin -r stdout:/path-to/fout -r stderr:/path-to/ferr` 。 调试期间,当希望观察被调试进程要读取什么数据、是否在等待数据输入、是否读取成功时,就可以通过 `tail -f /path-to/fout /path-to/ferr` 来观察,通过 `echo data >> /path-to/fin` 来输入。 ### 本节小结 本节简要介绍了调试器前端UI层的一些设计,包括命令行调试界面、调试命令管理、用户交互管理,在后面的实现部分我们将进一步结合源码来展开。 ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/3-Service层设计.md ================================================ ## Service层设计 arch 调试器前后端分离式架构,调试器的前端和后端需要通过service层进行通信。尽管调试器调试存在本地调试、远程调试两种类型,但是从架构设计上来看,后端都是希望以API调用的方式来处理请求、响应。 远程调试场景下,前端、后端是以C/S协议请求方式来交互,后端自然是以API调用的方式来提供服务的;对于本地调试场景下,为了实现架构上的优雅统一,此时进程内逻辑会一分为二,一部分是前端逻辑,一部分是后端逻辑,它们之间通过net.Pipe进行协议通信。 - 远程调试,通过真正的C/S网络通信来完成调试请求发送、处理、响应,为了简化收包、解包、编解码、序列化的问题,我们可以直接使用go标准库提供的JSON-RPC实现来完成调试器前后端的网络通信; - 本地调试,在net.Pipe基础上实现一个 `preConnectedListener`,它实现了net.Listener接口,这样可以通过统一的Accept操作来完成前端、后端连接的建立; 这样整个service层的通信接口就可以统一用网络层的通信接口来完成连接建立,进而统一通过API调用的方式来完成请求发送、处理、响应,整体代码处理逻辑就非常优雅,接下来会详细介绍。 ### 远程调试:JSON-RPC over network 概要设计中提到了远程调试情况下,调试器前后端需要通过网络进行通信,我们采用json-rpc的方式来实现前后端的通讯。远程调试情况下,调试器前后端service层的设计如下。

service-jsonrpc

RPC是client/server架构设计中常见的一种通讯模式,它的理念是让client端能够像本地方法调用一样来完成对服务端同名接口处理函数的请求,底层的服务发现、频控、熔断、序列化、编解码、网络通讯等细节全部在桩代码以及更底层的框架中予以解决。 我们希望让调试器前后端通过RPC方式来通讯,这对以后我们扩展协议、简化编码复杂度很有价值,而我们又不希望额外引入GRPC等这么种重的框架,该怎么办呢?go标准库对http、json-rpc提供了很好的支持,我们在标准库基础上完成json-rpc通讯。当然了,如果client、server运行在相同host上时,也可以考虑基于UnixConn进行通信。 > 在本书配套的调试器实现demo中,网络通讯、RPC是实现前后端分离式架构的基础,但是并不是实现go符号级调试器最困难的部分。 > > 作者假定读者朋友已经掌握了RPC相关的知识,因此不会在本书正文部分对相关内容进行大篇幅的介绍。如您对相关内容感兴趣,可以自行从网络上检索相关资料。 ### 本地调试:JSON-RPC over net.Pipe 本地调试时调试器前后端该如何通讯呢?我们熟知的进程间通信手段有很多,比如pipe、fifo、shm等。而在go程序中,goroutines之间通讯广泛采用通信串行处理的思想(Communicating Sequential Processes,简称CSP),即通过chan通信。 go标准库在chan的基础上封装了net.pipe,net.pipe内部包含了两个chan,分别用于读操作(readonly)和写操作(writeonly): - rdRx,只可读的chan,用来读取管道另一端发送的数据; - rdTx,只可写的chan,用来向管道另一端发送数据; ```go type pipe struct { wrMu sync.Mutex // Serialize Write operations // Used by local Read to interact with remote Write. // Successful receive on rdRx is always followed by send on rdTx. rdRx <-chan []byte rdTx chan<- int // Used by local Write to interact with remote Read. // Successful send on wrTx is always followed by receive on wrRx. wrTx chan<- []byte wrRx <-chan int once sync.Once // Protects closing localDone localDone chan struct{} remoteDone <-chan struct{} readDeadline pipeDeadline writeDeadline pipeDeadline } ``` `net.Pipe() (Conn, Conn)`个函数,则会为我们准备好这样一条全双工的管道,并返回两个net.Conn实例,其实就是net.pipe,然后我们就可以通过net.Conn的Read、Write函数像进行网络操作一样实现同一进程内的全双工通信了。 ```go // Pipe creates a synchronous, in-memory, full duplex // network connection; both ends implement the Conn interface. // Reads on one end are matched with writes on the other, // copying data directly between the two; there is no internal // buffering. func Pipe() (Conn, Conn) { cb1 := make(chan []byte) cb2 := make(chan []byte) cn1 := make(chan int) cn2 := make(chan int) done1 := make(chan struct{}) done2 := make(chan struct{}) p1 := &pipe{ rdRx: cb1, rdTx: cn1, wrTx: cb2, wrRx: cn2, localDone: done1, remoteDone: done2, readDeadline: makePipeDeadline(), writeDeadline: makePipeDeadline(), } p2 := &pipe{ rdRx: cb2, rdTx: cn2, wrTx: cb1, wrRx: cn1, localDone: done2, remoteDone: done1, readDeadline: makePipeDeadline(), writeDeadline: makePipeDeadline(), } return p1, p2 } ``` 所以设计图上来看,通过net.Pipe进行通信,与通过json-rpc通讯时的差异并不是很明显。具体差异主要体现在,它不走网络,也不需要走http协议。这样统一于net.Conn的通信操作,使得我们在编码实现调试器前后端通讯时会更清晰简洁。

service-pipelistener

然后,我们需要再考虑下面几个问题,net.Pipe()虽然返回了net.Conn供我们进行全双工通信,但是: - 服务端往往是先创建net.Listener然后Accept客户端连接请求才能创建net.Conn; 我们可以创建一个实现了net.Listener接口的新类型preconnectedListener,其内部保存 `net.Pipe() (Conn, Conn)`返回的一个net.Conn,每当调用Accept的时候直接返回该保存的net.Conn即可。 - 客户端往往是通过net.Dial然后才能创建net.Conn; `net.Pipe() (Conn, Conn)`,其返回的另一个Conn作为client的net.Dial的net.Conn,client就不用net.Dial来创建连接了。 这样,当本地调试时,我们就不通过 `net.Listen(network, address)`而是通过 `net.ListenerPipe()`来返回preconnectedListener来作为net.Listener即可。 ### 有哪些RPC要支持 前端UI层设计中我们列出了一些调试命令,包括启动调试的一些子命令 `attach exec debug trace ...`,还有一些调试会话中的交互式命令 `breakpoint continue step print ...`。这些调试命令执行时,调试器前端会调用对应的调试器后端的1个API接口或者多个相关的API接口,来请求调试器后端完成响应处理。 以下Client接口定义,体现了调试器需要暴露给客户端调用的一些方法,每个Client接口方法都是一个方法调用约定,对应的有调试器后端的实现、调试器前端的桩代码调用。调试器前端接收并执行某个调试命令时,调用client的1个或者多个方法,并结合一些前端的计算、转换、展示,最终实现该调试命令。 ```go // Client represents a client of a debugger service. All client methods are synchronous. type Client interface { // ProcessPid returns the pid of the process we are debugging. ProcessPid() int // BuildID returns the BuildID of the process' executable we are debugging. BuildID() string // Detach detaches the debugger, optionally killing the process. Detach(killProcess bool) error // Restart restarts program. Set true if you want to rebuild the process we are debugging. Restart(rebuild bool) ([]api.DiscardedBreakpoint, error) // RestartFrom restarts program from the specified position. RestartFrom(rerecord bool, pos string, resetArgs bool, newArgs []string, newRedirects [3]string, rebuild bool) ([]api.DiscardedBreakpoint, error) // GetState returns the current debugger state. GetState() (*api.DebuggerState, error) // GetStateNonBlocking returns the current debugger state, returning immediately if the target is already running. GetStateNonBlocking() (*api.DebuggerState, error) // Continue resumes process execution. Continue() <-chan *api.DebuggerState // Rewind resumes process execution backwards. Rewind() <-chan *api.DebuggerState // DirectionCongruentContinue resumes process execution, if a reverse next, step or stepout operation is in progress it will resume execution backward. DirectionCongruentContinue() <-chan *api.DebuggerState // Next continues to the next source line, not entering function calls. Next() (*api.DebuggerState, error) // ReverseNext continues backward to the previous line of source code, not entering function calls. ReverseNext() (*api.DebuggerState, error) // Step continues to the next source line, entering function calls. Step() (*api.DebuggerState, error) // ReverseStep continues backward to the previous line of source code, entering function calls. ReverseStep() (*api.DebuggerState, error) // StepOut continues to the return address of the current function. StepOut() (*api.DebuggerState, error) // ReverseStepOut continues backward to the caller of the current function. ReverseStepOut() (*api.DebuggerState, error) // Call resumes process execution while making a function call. Call(goroutineID int64, expr string, unsafe bool) (*api.DebuggerState, error) // StepInstruction will step a single cpu instruction. StepInstruction(skipCalls bool) (*api.DebuggerState, error) // ReverseStepInstruction will reverse step a single cpu instruction. ReverseStepInstruction(skipCalls bool) (*api.DebuggerState, error) // SwitchThread switches the current thread context. SwitchThread(threadID int) (*api.DebuggerState, error) // SwitchGoroutine switches the current goroutine (and the current thread as well) SwitchGoroutine(goroutineID int64) (*api.DebuggerState, error) // Halt suspends the process. Halt() (*api.DebuggerState, error) // GetBreakpoint gets a breakpoint by ID. GetBreakpoint(id int) (*api.Breakpoint, error) // GetBreakpointByName gets a breakpoint by name. GetBreakpointByName(name string) (*api.Breakpoint, error) // CreateBreakpoint creates a new breakpoint. CreateBreakpoint(*api.Breakpoint) (*api.Breakpoint, error) // CreateBreakpointWithExpr creates a new breakpoint and sets an expression to restore it after it is disabled. CreateBreakpointWithExpr(*api.Breakpoint, string, [][2]string, bool) (*api.Breakpoint, error) // CreateWatchpoint creates a new watchpoint. CreateWatchpoint(api.EvalScope, string, api.WatchType) (*api.Breakpoint, error) // ListBreakpoints gets all breakpoints. ListBreakpoints(bool) ([]*api.Breakpoint, error) // ClearBreakpoint deletes a breakpoint by ID. ClearBreakpoint(id int) (*api.Breakpoint, error) // ClearBreakpointByName deletes a breakpoint by name ClearBreakpointByName(name string) (*api.Breakpoint, error) // ToggleBreakpoint toggles on or off a breakpoint by ID. ToggleBreakpoint(id int) (*api.Breakpoint, error) // ToggleBreakpointByName toggles on or off a breakpoint by name. ToggleBreakpointByName(name string) (*api.Breakpoint, error) // AmendBreakpoint allows user to update an existing breakpoint for example to change the information // retrieved when the breakpoint is hit or to change, add or remove the break condition AmendBreakpoint(*api.Breakpoint) error // CancelNext cancels a Next or Step call that was interrupted by a manual stop or by another breakpoint CancelNext() error // ListThreads lists all threads. ListThreads() ([]*api.Thread, error) // GetThread gets a thread by its ID. GetThread(id int) (*api.Thread, error) // ListPackageVariables lists all package variables in the context of the current thread. ListPackageVariables(filter string, cfg api.LoadConfig) ([]api.Variable, error) // EvalVariable returns a variable in the context of the current thread. EvalVariable(scope api.EvalScope, symbol string, cfg api.LoadConfig) (*api.Variable, error) // SetVariable sets the value of a variable SetVariable(scope api.EvalScope, symbol, value string) error // ListSources lists all source files in the process matching filter. ListSources(filter string) ([]string, error) // ListFunctions lists all functions in the process matching filter. ListFunctions(filter string, tracefollow int) ([]string, error) // ListTypes lists all types in the process matching filter. ListTypes(filter string) ([]string, error) // ListPackagesBuildInfo lists all packages in the process matching filter. ListPackagesBuildInfo(filter string, includeFiles bool) ([]api.PackageBuildInfo, error) // ListLocalVariables lists all local variables in scope. ListLocalVariables(scope api.EvalScope, cfg api.LoadConfig) ([]api.Variable, error) // ListFunctionArgs lists all arguments to the current function. ListFunctionArgs(scope api.EvalScope, cfg api.LoadConfig) ([]api.Variable, error) // ListThreadRegisters lists registers and their values, for the given thread. ListThreadRegisters(threadID int, includeFp bool) (api.Registers, error) // ListScopeRegisters lists registers and their values, for the given scope. ListScopeRegisters(scope api.EvalScope, includeFp bool) (api.Registers, error) // ListGoroutines lists all goroutines. ListGoroutines(start, count int) ([]*api.Goroutine, int, error) // ListGoroutinesWithFilter lists goroutines matching the filters ListGoroutinesWithFilter(start, count int, filters []api.ListGoroutinesFilter, group *api.GoroutineGroupingOptions, scope *api.EvalScope) ([]*api.Goroutine, []api.GoroutineGroup, int, bool, error) // Stacktrace returns stacktrace Stacktrace(goroutineID int64, depth int, opts api.StacktraceOptions, cfg *api.LoadConfig) ([]api.Stackframe, error) // Ancestors returns ancestor stacktraces Ancestors(goroutineID int64, numAncestors int, depth int) ([]api.Ancestor, error) // AttachedToExistingProcess returns whether we attached to a running process or not AttachedToExistingProcess() bool // FindLocation returns concrete location information described by a location expression // loc ::= : | [:] | // | (+|-) | | *
// * can be the full path of a file or just a suffix // * ::= .. | .(*). | . | . | (*). | // * must be unambiguous // * // will return a location for each function matched by regex // * + returns a location for the line that is lines after the current line // * - returns a location for the line that is lines before the current line // * returns a location for a line in the current file // * *
returns the location corresponding to the specified address // NOTE: this function does not actually set breakpoints. // If findInstruction is true FindLocation will only return locations that correspond to instructions. FindLocation(scope api.EvalScope, loc string, findInstruction bool, substitutePathRules [][2]string) ([]api.Location, string, error) // DisassembleRange disassemble code between startPC and endPC DisassembleRange(scope api.EvalScope, startPC, endPC uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) // DisassemblePC disassemble code of the function containing PC DisassemblePC(scope api.EvalScope, pc uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) // Recorded returns true if the target is a recording. Recorded() bool // TraceDirectory returns the path to the trace directory for a recording. TraceDirectory() (string, error) // Checkpoint sets a checkpoint at the current position. Checkpoint(where string) (checkpointID int, err error) // ListCheckpoints gets all checkpoints. ListCheckpoints() ([]api.Checkpoint, error) // ClearCheckpoint removes a checkpoint ClearCheckpoint(id int) error // SetReturnValuesLoadConfig sets the load configuration for return values. SetReturnValuesLoadConfig(*api.LoadConfig) // IsMulticlient returns true if the headless instance is multiclient. IsMulticlient() bool // ListDynamicLibraries returns a list of loaded dynamic libraries. ListDynamicLibraries() ([]api.Image, error) // ExamineMemory returns the raw memory stored at the given address. // The amount of data to be read is specified by length which must be less than or equal to 1000. // This function will return an error if it reads less than `length` bytes. ExamineMemory(address uint64, length int) ([]byte, bool, error) // StopRecording stops a recording if one is in progress. StopRecording() error // CoreDumpStart starts creating a core dump to the specified file CoreDumpStart(dest string) (api.DumpState, error) // CoreDumpWait waits for the core dump to finish, or for the specified amount of milliseconds CoreDumpWait(msec int) api.DumpState // CoreDumpCancel cancels a core dump in progress CoreDumpCancel() error // ListTargets returns the list of connected targets ListTargets() ([]api.Target, error) // FollowExec enables or disables the follow exec mode. In follow exec mode // Delve will automatically debug child processes launched by the target // process FollowExec(bool, string) error FollowExecEnabled() bool // Disconnect closes the connection to the server without sending a Detach request first. // If cont is true a continue command will be sent instead. Disconnect(cont bool) error // SetDebugInfoDirectories sets directories used to search for debug symbols SetDebugInfoDirectories([]string) error // GetDebugInfoDirectories returns the list of directories used to search for debug symbols GetDebugInfoDirectories() ([]string, error) // GuessSubstitutePath tries to guess a substitute-path configuration for the client GuessSubstitutePath() ([][2]string, error) // CallAPI allows calling an arbitrary rpc method (used by starlark bindings) CallAPI(method string, args, reply interface{}) error } ``` 您现在开始感到了惊讶,怎么需要这么多接口?如果我们是做个玩具,那它会相对来说比较简单;如果我们是做个达到可用水准的工具,它就没那么简单了。上述接口 `go-delve/delve` 都已经实现,在我们的demo调试器中,由于篇幅原因,我们只会讲述哪些最核心的接口的实现,其他的接口读者可以自行实现,或者参考下delve的实现。 ### 本节小结 本节介绍了调试器前后端分离式架构下Service层的设计,包括了远程调试、本地调试时的的详细设计说明,最后也给出了我们要支持的RPC接口列表,换言之我们接下来的任务就是围绕着在前后端去实现这些RPC接口列表。 > ps: 与调试器进行交互,除了通过调试器前端显示输入调试命令,还需要一些更友好的方式,比如希望将当前调试会话进行保存,后面从这里继续进行调试。或者希望将一个完整的调试过程分享给其他人一起协助定位问题。go-delve/delve 允许用户通过编写starlark脚本的方式来完成这个操作,调试器会话内通过 `source /path-to/your.star` 来自动执行脚本中的调试操作,这个是非常方便的。starlark脚本中可以执行dlv预先支持好的一些函数,如 `dlv_command("会话中的调试命令")` 来执行调试命令,最终还是会转换成通过API调用的方式去调用调试器后端中的实现逻辑。作为调试器交互逻辑的补充,这里我们简单提一下,我们后面会对此进行详细介绍。 ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/4-后端符号层设计.md ================================================ ## 后端符号层设计 arch 本节我们来介绍下调试器后端的符号层设计,这里的符号层指的就是利用DWARF调试信息建立起指令地址和源码层面的视图转换,比如指令地址对应的源码位置、调用栈,或者数据地址对应的数据类型。在第8章中我们介绍了DWARF调试信息标准,也提供了一些通过DWARF获取变量、类型、行号表等信息的示例,大家应该对DWARF是什么、怎么用有了一定的认识了。 由于调试信息标准以及编译工具链生成调试信息的复杂性,即便是诞生这么多年的DWARF标准、go、delve,也还是在不断演进优化中。从go1.25开始将默认生成DWARFv5的调试信息,而DWARFv6出版草案也已经完成了。这是一个不会停止、不断演进中的过程。不过作者写这本书时,中间经历了很多变故,从go1.13到go1.24,go工具链DWARF信息生成及解析都发生了一些变化 …… 我们就不追着最新版go1.25去介绍了,我们还是按照go1.24及DWARFv4进行介绍。 > The compiler and linker in Go 1.25 now generate debug information using DWARF version 5; the newer DWARF version reduces the space required for debugging information in Go binaries. ### DWARFv4 sections DWARFv4,ELF文件中对应的debug sections及其存储内容如下: | Section | Description | | --------------- | --------------------------------------------------------------------- | | .debug_abbrev | 存储.debug_info中使用的缩写信息 | | .debug_arranges | 存储一个加速访问的查询表,通过内存地址查询对应编译单元信息 | | .debug_frame | 存储调用栈帧信息 | | .debug_info | 存储核心DWARF数据,包含了描述变量、代码等的DIEs | | .debug_line | 存储行号表程序 (程序指令由行号表状态机执行,执行后构建出完整的行号表) | | .debug_loc | 存储location描述信息 | | .debug_macinfo | 存储宏相关描述信息 | | .debug_pubnames | 存储一个加速访问的查询表,通过名称查询全局对象和函数 | | .debug_pubtypes | 存储一个加速访问的查询表,通过名称查询全局类型 | | .debug_ranges | 存储DIEs中引用的address ranges | | .debug_str | 存储.debug_info中引用的字符串表,也是通过偏移量来引用 | | .debug_types | 存储描述数据类型相关的DIEs | 这些debug sections之间的引用关系入下图所示(详情see:DWARFv4 Appendix B)。 dwarfv4-sections 为了更好地了解这部分,您可以利用aarzilli写的小工具 [aarzilli/diexplorer](https://github.com/aarzilli/diexplorer) or 我写的 [hitzhangjie/dwarfviewer](https://github.com/hitzhangjie/dwarfviewer),来浏览 debug sections 中的调试信息,非常有助于加深理解。 ### DWARFv4读写查询 为了更好地实现对DWARFv4 数据的读写,方便对不同形式DWARF数据的查询转换,设计了如下packages。 | package | 作用及用途 | | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | dwarfbuilder | 实现了一个Builder,通过该Builder可以方便地生成不同代码结构对应的DWARF调试信息,如New()返回一个Builder并初始设置DWARF信息的header字段,然后通过返回的builder增加编译单元、数据类型、变量、函数等等。`
`可以说,这个Builder为快速为源码生成对应的调试信息提供了很大遍历。但是这个package对于实现调试器而言应该是没多大用处的,但是对于验证go编译工具链如何生成调试信息很有帮助。一旦能认识到go编译工具链是如何生成DWARF调试信息的,我们就可以进一步了解到该如何去解析、应用对应的调试信息。`
`这个package的作用更多地是用于学习、验证DWARF调试信息生成和应用的。 | | frame | .[z]debug_frame中的信息可以帮助构建CFI (Canonical Frame Information),指定任意指令地址,我们便可以借助CFI计算出当前的调用栈。`
`DWARF信息中的编译单元可能压缩了多个go源文件,每个编译单元都以CIE (Common Information Entry) 开始,然后接下来是一系列的FDE (Frame Description Entry)。`
`这里定义了类型CommonInformationEntry、FrameDescriptionEntry用来分别表示CIE、FDE。FDE里面引用CIE,CIE中包含了初始指令序列,FDE中包含了自己的指令序列,结合CIE、FDE可以构建出完整的CFI表。`
`为了方便判断某个指令地址是否在某个FDE范围内,类型FrameDescriptionEntry中定义了方法Cover,还提供了Begin、End来给出该FDE的范围,此外它还定义了方法EstablishFrame通过状态机执行CIE、FDE中的指令序列来按需构建CFI表的全部或者一部分,方便我们计算CFA (Canonical Frame Address) ,有了它可以进一步计算出被调函数的返回地址。`
`有了这个返回地址,它实际是个指令地址,我们就可以计算出对应的源码位置(如文件名、行号、函数名)。将这个返回地址继续作为指令地址去迭代处理,我们就可以计算出完整的调用栈。`

`**注意:FDE中的begin、end描述的是创建、销毁栈帧及其存在期间的指令序列instructions的地址范围,详见DWARF v4 standard。**`
`此外还定义了类型FrameDescriptionEntries,它实际上是一个FDE的slice,只是增加了一些帮助函数,比如FDEForPC用于通过指令地址查询包含它的FDE。`
`每个函数都有一个FDE,每个函数的每条指令都是按照定义时的顺序来安排虚拟的内存地址的,不存在一个函数的FDE的指令范围会包括另一个函数的FDE的指令范围的情况)。 | | godwarf | 这个包提供了一些基础的功能,addr.go中提供了DWARF v5中新增的.[z]debug_addr的解析能力。`
`sections.go中提供了读取不同文件格式中调试信息的功能,如GetDebugSectionElf能从指定elf文件中读取指定调试section的数据,并且根据section数据是否压缩自动解压缩处理。`
`tree.go提供了读取DIE构成的Tree的能力,一个编译单元如果不连续的话在Tree.Ranges中就存在多个地址范围,当判断一个编译单元的地址范围是否包含指定指令地址时就需要遍历Tree.Ranges进行检查,Tree.ContainsPC方法简化了这个操作。Tree.Type方法还支持读取当前TreeNode对应的类型信息。`
`type.go中定义了对应go数据类型的一些类型,包括基本数据类型BasicType以及基于组合扩展的CharType、UcharType、IntType等,也包括一些组合类型如StructType、SliceType、StringType等,还有其他一些类型。这些类型都是以DIE的形式存储在.[z]debug_info中的。tree.go中提供了一个非常重要的函数ReadType,它能从DWARF数据中读取定义在指定偏移量处的类型信息,并在对应类型中通过reflect.Kind来建立与go数据类型的对应关系,以后就可以很方便地利用go的reflect包来创建变量并赋值。 | | line | 符号级调试很重要的一点是能够在指令地址与源文件名:行号之间进行转换,比如添加给语句添加断点的时候要转化成对指令地址的指令patch,或者停在某个断点处时应该显示出当前停在的源代码位置。行号表就是用来实现这个转换的,行号表被编码为一个字节码指令流,存储在.[z]debug_line中。`
`每个编译单元都有一个行号表,不同的编译单元的行号表数据最终会被linker合并在一起。每个行号表都有固定的结构以供解析,如header字段,然后后面跟着具体数据。`
`line_parser.go中提供了方法ParseAll来解析.[z]debug_line中的所有编译单元的行号表,对应类型DebugLines表示,每个编译单元对应的行号对应类型DebugLineInfo。DebugLineInfo中很重要的一个字段就是指令序列,这个指令序列也是交给一个行号表状态机去执行的,状态机实现定义在state_machine.go中,状态机执行后就能构建出完整的行号表。`
`有了完整的行号表,我们就可以根据pc去查表来找到对应的源码行。 | | loclist | 描述对象在内存中的位置可以用位置表达式,也可以用位置列表。如果在对象生命周期中对象的位置可能发生变化,那么就需要一个位置列表来描述。再者,如果一个对象在内存中的存储不是一个连续的段,而是多个不相邻的段合并起来,那这种也需要用位置列表来描述。`
`在DWARF v2~v4中,位置列表信息存储在.[z]debug_loc中,在DWARF v5中,则存储在.[z]debug_loclist中。loclist包分别针对旧版本(DWARF v2~v4)、新版本(DWARF v5)中的位置列表予以了支持。`
`这个包中定义了Dwarf2Reader、Dwarf5Reader分别用来从旧版本、新版本的位置列表原始数据中读取位置列表。 | | op | 先看op.go,DWARF中前面讲述地址表达式的运算时,提到了地址运算是通过执行一个基于栈操作的程序指令列表来完成的。程序指令都是1字节码指令,这里的字节码在当前package中均有定义,其需要的操作数就在栈中,每个字节码指令都有一个对应的函数stackfn,该函数执行时会对栈中的数据进行操作,取操作数并将运算结果重新入栈。最终栈顶元素即结果。`
`opcodes.go中定义了一系列操作码、操作码到名字映射、操作码对应操作数数量。`
`registers.go定义了DWARF关心的寄存器列表的信息DwarfRegisters,还提供了一些遍历的方法,如返回指定编号对应的的寄存器信息DwarfRegister、返回当前PC/SP/BP寄存器的值。 | | reader | 该包定义了类型Reader,它内嵌了go标准库中的dwarf.Reader来从.[z]debug_info中读取DIE信息,每个DIE在DWARF中被组织成一棵树的形式,每个DIE对应一个dwarf.Entry,它包括了此前提及的Tag以及[]Field(Field中记录了Attr信息),此外还记录了DIE的Offset、是否包含孩子DIE。`
`这里的Reader,还定义了一些其他函数如Seek、SeekToEntry、AddrFor、SeekToType、NextType、SeekToTypeNamed、FindEntryNamed、InstructionsForEntryNamed、InstructionsForEntry、NextMemberVariable、NextPackageVariable、NextCompileUnit。`
`该包还定义了类型Variable,其中嵌入了描述一个变量的DIE构成的树godwarf.Tree。它还提供了函数Variables用来从指定DIE树中提取包含的变量列表。 | | regnum | 定义了寄存器编号与寄存器名称的映射关系,提供了函数快速双向查询。 | | leb128 | 实现了几个工具函数:从一个sleb128编码的reader中读取一个int64;从一个uleb128编码的reader中读取一个uint64;对一个int64按sleb128编码后写入writer;对一个uint64按uleb128编码后写入writer。 | | dwarf | 实现了几个工具函数:从DWARF数据中读取基本信息(长度、dwarf64、dwarf版本、字节序),读取包含的编译单元列表及对应的版本信息,从buffer中读取DWARF string,从buffer中按指定字节序读取Uint16、Uint32、Uint64,按指定字节序编码一个Uint32、Uint64并写入buffer。 | 手写一个完备的DWARF解析库,要精通DWARF调试信息标准,还要了解go编译工具链在从DWARF v4演变到DWARF v5的过程中所做的各种调整,工作量还是很大的。为了避免大家学习过程过于枯燥,我们不会再手写一个新的DWARF支持库,而是复用go-delve/delve中的实现。 OK,下面来看下这些上述packages内部是如何组织的。 #### dwarfbuilder package dwarfbuilder主要是用来生成DWARF调试信息的,我们什么时候会需要生成DWARF调试信息呢?我们希望能让运行中的go程序生成core文件。我们通常会在go程序crash时得到一个core文件(启动时要加环境变量GOTRACEBACK=crash),其实也可以在程序运行期间动态地让其生成一个core文件。 core文件在Linux下也是ELF格式的,我们要写入一些可以用于调试的DWARF信息,dwarfbuilder就是用来生成这里的DWARF数据的(主要是.[z]debug_info中的数据)。 ![pkg dwarfbuilder](assets/4-后端符号层设计/dwarfbuilder.png) #### frame .[z]debug_frame中的数据可以用来构建调用栈信息表,当在程序执行期间可以用来构建调用栈,并能允许我们在调用栈上往前往后选择栈帧,并查看处于这个栈帧中的变量信息。当然也可以通过bt打印当前的调用栈信息。 .[z]debug_frame中的信息主要由一系列CIE(每个编译单元一个)以及一系列的FDE(每个编译单元中有很多的FDE)构成。实际解析完这里的数据后就能得到一个FrameDescriptionEntries,并在其上面封装了几个常用方法,如FDEForPC,当我们指定了一个指令地址时,它能够返回对应的栈帧。 当我们指定了一个PC值,执行它的EstablishFrame将执行调用栈信息表中的指令,完成之后就可以构建出一个FrameContext,其中就记录了此时的CFA、Regs、RetAddrReg数据,有了这些数据就可以算出当前函数的返回地址,从而进一步计算出调用函数对应的栈帧……重复这个过程就能够计算出调用栈。 ![pkg frame](assets/4-后端符号层设计/frame.png) #### line .[z]debug_line中记录着指令地址与源码文件的行号表信息,这张表中的数据将协助我们完成源码地址与指令地址之间的相互转换。 这个package的设计大致如下所示,`line.Parse(*DebugLineInfo, *bytes.Buffer)`,.[z]debug_info中的数据即为第二个参数,解析完后*DebugLineInfo中的数据将被填充好。 解析的过程中,需要用到formReader来从*bytes.Buffer中读取并解析数据,这张表在建立过程中需要用到这里的StateMachine来执行其中的字节码指令来完成表的重建。 最终,我们可以通过DebugLineInfo中的导出方法来实现一些常用操作,如PCToLine将PC地址转换为源文件地址,LineToPC将源文件地址转换为指令地址PC等。 ![pkg line](assets/4-后端符号层设计/line.png) #### loclist .[z]debug_loc、.[z]debug_loclist中存储了一些地址相关的信息,前者是DWARF standard v2中定义的,后者是DWARF standard v5中定义的,后者比前者更节省存储空间、效率也更高。 ![pkg loclist](assets/4-后端符号层设计/loclist.png) 这部分数据描述了对象的地址,当要描述一个对象的地址时,如果在其整个生命周期内它的地址可能会改变的话,就需要通过loclists来描述。什么情况下会出现这种情况呢? 这里的位置改变并不是说对象会迁移(类似GC那样),它的意思是说随着程序一条条指令执行,PC值是一直在变化的,此时为了更快速的计算出对象地址,可能会在loclists中生成新的一条loc entry,这样通过查找对应的entry就可以快速计算出其地址。 比如有段这样的程序: ```c void delay(int num) { volatile int i; for(i=0; i: 0: e24dd008 sub sp, sp, #8 4: e3a03000 mov r3, #0 8: e58d3004 str r3, [sp, #4] c: e59d3004 ldr r3, [sp, #4] 10: e1500003 cmp r0, r3 14: da000005 ble 30 18: e59d3004 ldr r3, [sp, #4] 1c: e2833001 add r3, r3, #1 20: e58d3004 str r3, [sp, #4] 24: e59d3004 ldr r3, [sp, #4] 28: e1530000 cmp r3, r0 2c: bafffff9 blt 18 30: e28dd008 add sp, sp, #8 34: e12fff1e bx lr ``` 我们可以看到指令地址范围,现在我们继续看下对应的DWARF数据: ```bash <1><25>: Abbrev Number: 2 (DW_TAG_subprogram) <26> DW_AT_external : 1 <27> DW_AT_name : (indirect string, offset: 0x19): delay <2b> DW_AT_decl_file : 1 <2c> DW_AT_decl_line : 1 <2d> DW_AT_prototyped : 1 <2e> DW_AT_low_pc : 0x0 <32> DW_AT_high_pc : 0x38 <36> DW_AT_frame_base : 0x0 (location list) <3a> DW_AT_sibling : <0x59> ... <2><4b>: Abbrev Number: 4 (DW_TAG_variable) <4c> DW_AT_name : i <4e> DW_AT_decl_file : 1 <4f> DW_AT_decl_line : 3 <50> DW_AT_type : <0x60> <54> DW_AT_location : 0x20 (location list) ``` 我们看到了变量i的定义,从其属性DW_AT_location中看到其位置在location list中描述,因此也继续输出下其.debug_loc: ```bash Offset Begin End Expression 00000000 00000000 00000004 (DW_OP_breg13 (r13): 0) 00000000 00000004 00000038 (DW_OP_breg13 (r13): 8) 00000000 00000020 0000000c 00000020 (DW_OP_fbreg: -12) 00000020 00000024 00000028 (DW_OP_reg3 (r3)) 00000020 00000028 00000038 (DW_OP_fbreg: -12) 00000020 ``` 我们可以看到当指令地址从4到38时其位置的计算表达式为r13+8,当其地址为c到20时其地址为fbreg-12,当地址为24到28时其地址为r3,当地址为28到38时其地址为fbreg-12。可见这里的位置改变是说的随着指令地址PC值的改变,其位置计算的表达式发生了改变,而之所以发生改变,是因为随着指令执行过程中,某些寄存器被使用了不能再按照之前的表达式进行计算,所以才在.debug_loc或者.debug_loclists中生成了新的loclists entry。 > 该demo取自stackoverflow问答:https://stackoverflow.com/q/47359841。 #### godwarf .[z]debug_info中通过DIE Tree来描述定义的类型、变量、函数、编译单元等信息,DIE通过Tag、Attr来描述具体的对象,这些DIE可以构成一个Tree。 package godwarf提供了导出函数来简化DIE Tree的加载、解析逻辑。 ![pkg godwarf](assets/4-后端符号层设计/godwarf.png) #### op package op中最重要的一个导出函数,`op.ExecuteStackProgram(...)`执行一个DWARF位置表达式并返回计算出的地址值(int64类型表示),或者返回一个[]Piece,每个Piece描述了一个位置,这里的位置可能是在内存(地址为Piece.Val)、寄存器(编号在Piece.Val)、立即数(Piece.Val或Piece.Bytes)。 op.DwarfRegisters、op.DwarfRegister这两个类型比较重要,它定义了stack program所需要用到的寄存器。相关的函数、方法逻辑也比较简单。 ![op](assets/4-后端符号层设计/op.png) #### reader 这里定义了DWARF读取解析.debug_info的一个更方便的reader,它建立在go标准库实现dwarf.Reader基础上。这个reader能够很方便地seek、读取变量、类型、函数、编译单元等程序要素。 ![reader](assets/4-后端符号层设计/reader.png) #### regnum 定义了一些寄存器号和对应的寄存器名, ```go // amd64架构下的寄存器列表 const ( AMD64_Rax = 0 AMD64_Rdx = 1 AMD64_Rcx = 2 AMD64_Rbx = 3 AMD64_Rsi = 4 AMD64_Rdi = 5 ... ) // 寄存器号到寄存器名的映射关系 var amd64DwarfToName = map[uint64]string{ AMD64_Rax: "Rax", AMD64_Rdx: "Rdx", AMD64_Rcx: "Rcx", AMD64_Rbx: "Rbx", ... } var AMD64NameToDwarf = func() map[string]int { r := make(map[string]int) for regNum, regName := range amd64DwarfToName { r[strings.ToLower(regName)] = int(regNum) } r["eflags"] = 49 r["st0"] = 33 r["st1"] = 34 r["st2"] = 35 r["st3"] = 36 r["st4"] = 37 r["st5"] = 38 r["st6"] = 39 r["st7"] = 40 return r }() // 返回最大寄存器编号 func AMD64MaxRegNum() uint64 { ... } // 返回寄存器编号对应的寄存器名 func AMD64ToName(num uint64) string { ... } ``` #### leb128 定义了一些读写leb128常用的工具函数: ```go // Reader is a io.ByteReader with a Len method. This interface is // satisfied by both bytes.Buffer and bytes.Reader. type Reader interface { io.ByteReader io.Reader Len() int } // DecodeUnsigned decodes an unsigned Little Endian Base 128 // represented number. func DecodeUnsigned(buf Reader) (uint64, uint32) {...} // DecodeSigned decodes a signed Little Endian Base 128 // represented number. func DecodeSigned(buf Reader) (int64, uint32) { ... } // EncodeUnsigned encodes x to the unsigned Little Endian Base 128 format. func EncodeUnsigned(out io.ByteWriter, x uint64) { ... } // EncodeSigned encodes x to the signed Little Endian Base 128 format. func EncodeSigned(out io.ByteWriter, x int64) { ... } ``` #### dwarf 定义了一些读写string、uint等常用的工具函数: ```go const ( _DW_UT_compile = 0x1 + iota _DW_UT_type _DW_UT_partial _DW_UT_skeleton _DW_UT_split_compile _DW_UT_split_type ) // ReadString reads a null-terminated string from data. func ReadString(data *bytes.Buffer) (string, error) { ... } // ReadUintRaw reads an integer of ptrSize bytes, with the specified byte order, from reader. func ReadUintRaw(reader io.Reader, order binary.ByteOrder, ptrSize int) (uint64, error) { ... } // WriteUint writes an integer of ptrSize bytes to writer, in the specified byte order. func WriteUint(writer io.Writer, order binary.ByteOrder, ptrSize int, data uint64) error { ... } // ReadDwarfLengthVersion reads a DWARF length field followed by a version field func ReadDwarfLengthVersion(data []byte) (length uint64, dwarf64 bool, version uint8, byteOrder binary.ByteOrder) { ... } // ReadUnitVersions reads the DWARF version of each unit in a debug_info section and returns them as a map. func ReadUnitVersions(data []byte) map[dwarf.Offset]uint8 { ... } ``` ### 本节小结 本节介绍了符号层调试信息这块的debug sections以及它们之间的联系,介绍了对这些DWARF数据进行读写用到的一些go packages的设计。这块我们就没有从0到1进行设计了,后续也不用从0到1去实现,我们阅读理解并复用go-delve/delve中沉淀了多年的实现就可以。 ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/5-后端目标层设计.md ================================================ ## 后端目标层设计 arch 调试器后端目标层是调试器架构中最底层的一层,它直接与被调试进程(tracee)交互,负责执行最基础的控制和数据操作。这一层的主要职责包括: ### 1. 执行控制 - 进程控制:启动、停止、继续执行被调试进程 - 断点管理:设置、清除、启用、禁用断点 - 单步执行:支持单步执行(step over)、单步进入(step into)、单步跳出(step out) - 执行状态查询:获取当前执行状态、PC值等 ### 2. 数据访问 - 内存操作:读取和写入进程内存空间 - 寄存器操作:读取和写入CPU寄存器 - 线程操作:获取线程列表、切换当前线程 - 栈操作:读取调用栈信息 ### 3. 原始数据处理 目标层处理的是最原始的二进制数据,它不关心数据的语义和类型。例如: - 读取内存时返回的是原始字节序列 - 读取寄存器时返回的是原始数值 - 这些原始数据需要传递给符号层进行解析和类型转换 ### 4. 与符号层的协作 目标层与符号层紧密协作: - 目标层提供原始数据访问能力 - 符号层负责解析DWARF调试信息 - 符号层将原始数据转换为有意义的类型化数据 - 两者配合实现源码级调试功能 ### 5. 平台适配 目标层需要适配不同的操作系统和硬件架构: - 支持不同的操作系统(Linux、Windows、macOS等) - 支持不同的CPU架构(x86、ARM等) - 处理平台特定的调试接口(如ptrace、debugger API等) ### 类图设计 ```plantuml @startuml interface Target { + Continue() error + Step() error + StepOver() error + StepOut() error + Breakpoint() error + ReadMemory(addr uint64, size int) ([]byte, error) + WriteMemory(addr uint64, data []byte) error + ReadRegister(reg string) (uint64, error) + WriteRegister(reg string, value uint64) error + GetThreads() ([]Thread, error) + GetCurrentThread() Thread } interface Thread { + GetRegisters() (Registers, error) + SetRegisters(Registers) error + GetStacktrace() ([]StackFrame, error) + GetPC() (uint64, error) + SetPC(uint64) error } interface Registers { + Get(reg string) (uint64, error) + Set(reg string, value uint64) error + GetPC() uint64 + GetSP() uint64 } class Process { - target Target - threads map[int]Thread - breakpoints map[uint64]*Breakpoint + Continue() error + Step() error + SetBreakpoint(addr uint64) error + ClearBreakpoint(addr uint64) error } class Breakpoint { - addr uint64 - enabled bool - originalData []byte + Enable() error + Disable() error + IsEnabled() bool } class MemoryReader { + ReadMemory(addr uint64, size int) ([]byte, error) + WriteMemory(addr uint64, data []byte) error } Target <|.. Process Thread <|.. ProcessThread Registers <|.. ProcessRegisters Process o-- Target Process o-- Thread Process o-- Breakpoint Process o-- MemoryReader @enduml ``` 这个类图展示了目标层的主要组件和它们之间的关系: 1. `Target` 接口定义了目标层需要实现的核心功能 2. `Thread` 接口定义了线程相关的操作 3. `Registers` 接口定义了寄存器访问操作 4. `Process` 类作为主要实现类,管理进程状态和调试操作 5. `Breakpoint` 类处理断点相关的操作 6. `MemoryReader` 类处理内存读写操作 这种设计使得目标层能够: - 提供统一的调试接口 - 支持多平台实现 - 方便扩展新的功能 - 清晰地分离关注点 ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/6-日志系统设计.md ================================================ ## tinydbg 日志系统设计 ### 多层次的调试器与日志挑战 现代调试器是一个复杂的系统,通常包含多个层次和组件,如核心调试引擎、RPC通信层、函数调用处理、堆栈跟踪等。在这种复杂的系统中,如果没有一个精心设计的日志系统,将会导致以下问题: 1. **问题定位困难**:当调试器出现问题时,难以快速定位问题发生在哪个层次或组件中 2. **日志混乱**:不同层次的日志混杂在一起,缺乏清晰的分类和标识 3. **信息不完整**:关键上下文信息缺失,难以理解日志产生的具体场景 4. **性能影响**:不当的日志记录可能会影响调试器的性能 因此,一个设计良好的日志系统对于调试器的开发和维护至关重要。 ### tinydbg 的日志系统设计 tinydbg 的日志系统基于 Go 1.21 的 `slog` 包实现,并进行了定制化设计。其核心设计特点如下: #### 层次化的日志分类 tinydbg 将日志分为多个层次,每个层次对应调试器的不同组件: - `debugger`:核心调试器层的日志 - `debuglineerr`:DWARF 行号信息处理相关的错误日志 - `rpc`:RPC 通信层的日志 - `fncall`:函数调用相关的日志 - `stack`:堆栈跟踪相关的日志 这种分类使得日志具有清晰的层次结构,便于问题定位和分析。 #### 灵活的日志配置 日志系统提供了灵活的配置选项: 1. **日志开关控制**: - 可以全局开启/关闭日志 - 可以单独控制每个层次的日志开关 2. **日志输出目标**: - 支持输出到文件描述符 - 支持输出到文件路径 - 默认输出到标准错误 3. **日志级别**: - 支持 Debug、Info、Warn、Error 四个级别 - 每个层次可以独立设置日志级别 #### 结构化的日志格式 日志系统采用了结构化的日志格式,每条日志包含: 1. **时间戳**:使用 RFC3339 格式 2. **日志级别**:小写形式(debug/info/warn/error) 3. **上下文属性**:以 key=value 形式展示 4. **日志消息**:具体的日志内容 示例日志格式: ``` 2024-03-21T10:30:45Z debug layer=debugger,kind=fncall message content ``` #### 自定义 Handler 实现 tinydbg 实现了自定义的 `textHandler`,它: 1. 重写了 `slog.Handler` 接口 2. 优化了日志格式化过程 3. 支持属性预格式化,提高性能 4. 实现了灵活的日志级别控制 #### 便捷的日志接口 提供了两组便捷的日志接口: 1. **格式化接口**: - `Debugf/Infof/Warnf/Errorf`:支持格式化字符串 2. **直接接口**: - `Debug/Info/Warn/Error`:直接输出参数 每个层次都提供了对应的 Logger 获取函数,如: - `LogDebuggerLogger()` - `LogDebugLineLogger()` - `RPCLogger()` - `FnCallLogger()` - `StackLogger()` ### 总结 tinydbg 的日志系统设计充分考虑了调试器的特点和需求: 1. **层次化设计**:通过清晰的层次划分,使日志具有更好的可读性和可维护性 2. **灵活性**:提供了丰富的配置选项,满足不同场景的需求 3. **性能优化**:通过预格式化等机制,确保日志记录不会影响调试器性能 4. **易用性**:提供了简单直观的接口,方便开发人员使用 这种设计不仅提高了调试器的可维护性,也为问题诊断和性能分析提供了有力支持。在实际使用中,开发人员可以快速定位问题,理解系统行为,提高开发效率。 ================================================ FILE: book/9-develop-sym-debugger/1-架构设计/README.md ================================================ ## 架构设计 本章开头,我们介绍了调试器在软件开发全生命周期中的必要性,这一节我们将来分析在真实的开发、测试、线上环境中软件调试面临的诸多挑战,包括多平台兼容性、远程调试、安全隔离、性能影响等等。 为了应对这些挑战,现代调试器普遍采用前后端分离式架构,并支持前后端的独立扩展、演进,我们常将调试器划分为调试器前端和调试器后端: - 前端可以划分为UI层和服务层: - UI层可扩展:以方便支持不同的调试界面,如dlv命令行界面、gdlv图形界面或者VSCode中的可视化调试插件; - 服务层可扩展:以方便支持本地调试(net.Pipe),或者远程调试(JSON-RPC),或者与更多的IDE进行集成(DAP协议); - 后端可以划分为服务层、符号层、目标层: - 服务层可扩展:略 - 符号层可扩展:可支持不同的文件格式(ELF,PE,MachO)、不同的调试符号信息(DWARF,COFF,Stabs)、不同编程语言(Go,C,C++,Rust); - 目标层可扩展:可支持不同的操作系统(Windows,Linux,macOS),不同的硬件平台(amd64,arm64,powerpc); OK,让我们一起了解下现代调试器面临的挑战,以及如何通过合理的架构设计来解决这些问题吧。Let's Go! ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/00-cmds.md ================================================ ## 核心调试命令 在第5章《调试器概貌》我们分析了下调试器的功能性需求、非功能性需求、大致的实现方案,第6章紧跟着介绍了指令级调试器的设计实现,第7章介绍了与调试器写作紧密相关的ELF文件格式、编译器、链接器、加载器的工作原理以及调试信息的生成,第8章专门介绍了调试信息对源程序数据和指令、进程运行时视图的描述。9.1开头对现代调试器的整体架构进行了介绍,本节就是要重点介绍每个调试功能的实现了。 在开始前,我们再次重申下调试器的功能性需求、非功能性需求,以及大致的技术方案。对于列出的完整的功能列表,由于篇幅和时间限制,我们没法做到全部实现、逐一介绍,因此我们会特别说明各个功能会实现到什么程度。由于我们的demo tinydbg是在 `go-delve/devle` 上裁剪、修改而来,所以我们可以直接标注清楚每个功能点我们做到了什么程度、相比delve的变化,方便大家了解。 ### 支持多种调试对象 | 命令 | 描述 | 对象类型 | 是否实现 | dlv是否实现 | | ------------ | ------------------------------ | --------------- | -------- | ----------- | | godbg attach | 调试一个正在运行中的process | process | Y | Y | | godbg exec | 启动并调试指定的go executable | executable | Y | Y | | godbg debug | 调试当前go main module | go main package | Y | Y | | godbg test | 调试当前go package中的测试函数 | go test package | N | Y | | godbg core | 启动并调试指定的coredump | coredump | Y | Y | > ps:为什么不支持 `godbg test` ? > > go语言有原生的单元测试框架,`go test` 大家对此应该不陌生,对于测试包的调试,我们可以这样做:`go test -c -ldflags 'all=-N -l'` 然后再 `godbg exec ./xxxx.test`,但是如果能够一条命令 `godbg test` 搞定上述构建、运行测试的操作,会便利一点。 > > 尽管如此,但这个并不涉及增量的核心调试逻辑,只是一个编译构建、启动测试的优化,为了让tinydbg更加精简、节省介绍篇幅,我们移除了原来dlv的实现逻辑。 ### 支持多种调试模式 | 命令 | 模式 | 是否实现 | dlv是否实现 | | ---------------------------------- | ------------------------------------------------------ | -------- | --------------------- | | godbg debug/exec/attach | 本地调试模式 | Y | Y | | godbg debug/exec/attach --headless | 启动调试服务器,允许调试客户端远程连接 (JSON-RPC) | Y | Y | | godbg connect | 启动调试客户端,连接远程调试服务器 | Y | Y | | godbg dap | 启动调试服务器,且支持DAP协议,允许VSCode等通过DAP集成 | N | Y | | godbg trace | 跟踪程序执行的函数 | bp-based | bp-based + ebpf-based | | godbg <...> --disable-aslr | 禁用ASLR地址随机化 | N | Y | | godbg --backend=gdb/lldb/rr | 使用其他调试器实现代替native实现 | N | Y | > ps: 描述下这里的裁剪逻辑? > > 1. 为什么去掉 `godbg dap` 支持? > > - 也是以--headless模式启动调试器服务端,只是协议编解码逻辑不是使用JSON-RPC,而是使用DAP; > - 尽管DAP是VSCode等IDE与调试器进行集成的一个流行的协议,但是它并不是调试器核心逻辑,我们知道它的用途即可; > 2. 为什么去掉 `godbg tracepoint` 的ebpf-based实现? > > - ebpf-based tracing这部分细节非常多,介绍Linux ebpf子系统、ebpf程序编写会花非常多篇幅; > - breakpoint-based tracing在内容上更紧凑,也可以实现tracing能力,尽管它性能比较差; > - 我们在扩展阅读部分提到了 ebpf-based tracing工具 [go-ftrace] 的设计实现,读者可以在这里了解更多; > 3. 为什么去掉对禁用ASLR的支持? > > - 这个之前我们介绍过ASLR是什么; > - 大家了解它对程序加载、程序调试(特别是保存会话并进行自动化调试)的影响即可。 > 4. 为什么去掉 `godbg --backend` 的实现? > > - 支持不同backend实现涉及到gdbserial支持,以及与gdb、mozillar rr对接,代码量大、介绍起来篇幅大; > - 支持lldb和支持gdb类似,而且我们对dlv项目已经裁剪到只支持linux/amd64,保留macOS lldb支持没意义; > - 先让大家掌握linux/amd64下的native backend实现,才是本书重点,我们会在扩展部分介绍如何进行这方面的扩展; > > 通过这里的部分裁剪,我们保留了符号级调试器的核心设计实现,而且篇幅也不会拉的很长,比较适合读者朋友学习。 ### 支持常见调试操作 #### 执行相关操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ---------------- | ---- | :----------------------------------------------------------- | -------- | ----------- | | call | - | Resumes process, injecting a function call. | Y | Y | | continue | c | Run until breakpoint or program termination. | Y | Y | | next | n | Step over to next source line. | Y | Y | | restart | r | Restart process. | Y | Y | | step | s | Single step through program. | Y | Y | | step-instruction | si | Single step a single cpu instruction. | Y | Y | | stepout | so | Step out of the current function. | Y | Y | | rewind | | Run backwards until breakpoint or start of recorded history. | N | Y | | checkpoints | | Print out info for existing checkpoints. | N | Y | | rev | | 类似gdb rnext, rstep...改变next、step、continue的direction | N | Y | > ps: 描述下这里不支持 rewind、checkpoints、rev 操作的原因? > > mozilla rr,使得一次录制后就可以稳定重放调试过程、确定性地进行调试,方便我们定位到故障源头,移除它的原因主要有: > > 1) 尽管在此基础上构建起确定性调试很美好,但是设计实现也会让调试器本身变得很复杂; > - 通过gdbserial与rr通信; > - 必要时与rr进行交互改变程序执行方向; > - 代码实现上要补充大量正向执行、反向执行的控制逻辑; > 2) rev改变程序执行方向的操作(影响命令next/step的方向),以及continue的反向版本rewind,这些功能的实现依赖rr backend; > 3) checkpoints的功能实现也依赖rr; > > 我们会在扩展阅读部分介绍 mozilla rr 的录制、重放原理,以及如何集成它,但是我们不会在 demo tinydbg 中保留这部分实现逻辑。 #### 断点相关操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ----------- | ---- | -------------------------------------------- | -------- | ----------- | | break | b | Sets a breakpoint. | Y | Y | | breakpoints | bp | Print out info for active breakpoints. | Y | Y | | clear | | Deletes breakpoint. | Y | Y | | clearall | | Deletes multiple breakpoints. | Y | Y | | condition | cond | Set breakpoint condition. | Y | Y | | on | | Executes a command when a breakpoint is hit. | Y | Y | | toggle | | Toggles on or off a breakpoint. | Y | Y | | trace | t | Set tracepoint. | Y | Y | 这些断点相关的操作,是比较常用的核心调试命令,这些操作的实现都会予以保留、介绍。 #### 数据读写操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ---------- | ---- | ------------------------------------------------ | -------- | ----------- | | args | | Print function arguments. | Y | Y | | display | | Disassembler. | Y | Y | | examinemem | x | Examine raw memory at the given address. | Y | Y | | locals | | Print local variables. | Y | Y | | print | p | Evaluate an expression. | Y | Y | | regs | | Print contents of CPU registers. | Y | Y | | set | | Changes the value of a variable. | Y | Y | | vars | | Print package variables. | Y | Y | | whatis | | Prints type of an expression. | Y | Y | | ptype | | Print type details, including fields and methods | Y | N | 这些读写寄存器、读写内存、查看实参、查看局部变量、打印变量、查看表达式类型、查看类型细节相关的操作,是调试过程中比较常用的核心调试命令,这些操作的实现我们也会予以保留、介绍。值得一提的是,gdb中的调试操作ptype可以打印一个变量的类型细节信息,dlv中的类似操作是whatis,但是whatis只能打印类型的字段信息,不能打印出类型上定义的方法集,这样的话就不是很方便。 所以我们希望支持一个新的调试命令ptype,这个过程中也可以让读者朋友们活学活用DWARF来进行调试器的功能扩展。 #### 线程协程切换 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ---------- | ---- | --------------------------------------- | -------- | ----------- | | goroutine | gr | Shows or changes current goroutine | Y | Y | | goroutines | grs | List program goroutines. | Y | Y | | thread | tr | Switch to the specified thread. | Y | Y | | threads | | Print out info for every traced thread. | Y | Y | 不同编程语言提供的并发编程接口也不同,如C、C++、Java、Rust等提供了面向线程的并发编程接口,而Go不同它提供的是面向协程goroutine的并发编程接口。但是软件调试在支持保护模式的操作系统上,调试的实现本质上是利用了内核提供的能力,比如Linux下基于ptrace操作实现对进程指令、数据的读写控制,实现对进程调度的控制、状态获取等等。Go比较特殊的是它实现了一个面向goroutine的调度系统,俗称GMP调度。P这个虚拟处理器资源上的任务队列(待调度执行的G),最终是由M执行的。G的调度由Go运行时GMP调度器调度,线程M的调度则由内核调度器控制,而调试器就是通过ptrace系统调用来影响内核对目标调试线程的调度,从而实现调试。 所以,对于Go调试器,为了更灵活地控制,需要知道当前有哪些线程 `threads`、有哪些协程 `goroutines`,以及在此基础上实现线程切换 `thread n`、协程切换 `goroutine m`。 #### 调用栈相关操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | -------- | ---- | --------------------------------------------------------------- | -------- | ----------- | | stack | bt | Print stack trace. | Y | Y | | frame | | Set the current frame, or execute command on a different frame. | Y | Y | | up | | Move the current frame up. | Y | Y | | down | | Move the current frame down. | Y | Y | | deferred | | Executes command in the context of a deferred call. | Y | Y | 这部分是跟调用栈相关的操作,bt查看调用栈、frame选择栈帧查看栈帧内的参数、变量状态,up、down方便我们在调用栈中移动,本质上和frame操作一样。deferred比较特殊,是面向Go语言defer函数的特别支持,我们一个函数可以有多个defer函数调用,`defer ` 可以方便对第n个defer函数添加断点,并且能够在执行到该处时执行特定的命令,如打印locals。 这些对于Go语言来说是比较常用的核心调试命令,我们均保留并予以介绍。 #### 源码相关操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ----------- | ------ | ----------------------------- | -------- | ----------- | | list | ls / l | Show source code. | Y | Y | | disassemble | disass | Disassembler. | Y | Y | | types | | Print list of types. | Y | Y | | funcs | | Print list of functions. | Y | Y | | libraries | | List loaded dynamic libraries | Y | Y | 这部分是跟源代码相关的操作,如查看源码、反汇编源码、查看类型列表、函数列表,以及源码依赖的共享库列表。这部分中list、disassemble是比较常用的操作,是我们重点介绍的对象。types、funcs在我们介绍通过DWARF可以获取什么的用例演示时已经介绍过了。 #### 脚本自动化调试 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ------- | ---- | --------------------------------------------------- | ---------------------- | ------------------------------------------- | | source | | Executes a file containing a list of delve commands | script of dlv commands | script of dlv commands + script of starlark | | sources | | Print list of source files. | Y | Y | 自动化调试过程中,我们会写好一些调试命令,然后调试会话中source后执行,这个用的可能不多,但是在特定场景下还是有点用处的,我们也需要简单介绍下。 > ps: 为什么移除了source对starlark脚本的支持? > > 1) 自动化调试可以通过编写了dlv commands的普通脚本来执行,这样也拥有了一定的自动化测试能力; > 2) 但是1)中方法没有starlark语言脚本灵活,starlark语言类似于python语言,starlark脚本中可以通过starlark binding代码直接调用debugger的内置调试操作。 > 3) 在2)基础上配合starlark的可编程能力对调试命令的输入、输出的处理,可以玩出更多花样,有更多的可探索的自动化调试空间; > > 我们会介绍Go语言程序中如何集成starlark,但是因为这个功能还不算是特别核心的调试能力,不过在demo tinydbg中,我们保留了两个分支: > > - 分支tinydbg:移除了linux/amd64无关代码,移除了backend gdb、lldb、mozilla rr代码,移除了record&replay、reverse debugging代码,移除了dap代码,……,但是保留了starlark实现,并且在examples目录中提供了一个starlark自动化测试的demo `starlark_demo`。如果您对此感兴趣,可以执行相关的测试; > - 分支tinydbg_minimal:在tinydbg分支裁剪现状的基础上,更加激进地进行了裁剪、重构,使得它的功能实现更向本章要介绍的内容靠拢 …… 一切从简,也包括删了starlark脚本支持; > > 您可以按需选择上述分支进行学习、测试,请知悉。 #### 其他一些操作 | 命令 | 别名 | 描述 | 是否实现 | dlv是否实现 | | ------- | -------- | -------------------------------------------------- | -------- | ----------- | | config | | Changes configuration parameters. | Y | Y | | dump | | Creates a core dump from the current process state | Y | Y | | edit | ed | Open where you are in$DELVE_EDITOR or $EDITOR | Y | Y | | rebuild | - | Rebuild the target executable and restarts it. | Y | Y | | exit | quit / q | Exit the debugger. | Y | Y | | help | h | Prints the help message. | Y | Y | 这些调试命令涉及到自定义配置、生成核心转储、查看或者修改源码、修改后重新编译,以及查看帮助、退出操作。这里的dump是我们要介绍的,它和core命令息息相关,一个生成、一个读取并调试。edit、rebuild有点亮点,解决了调试时发现问题后切换编辑器窗口编辑修改、再次调试的不便。exit、help就比较常规了。 上述调试命令能力大致是一个现代go符号级调试器所要支持的功能全集,可以达到工程上的应用要求了。如果读者有使用过go-delve/delve,你会发现上面的功能基本上全是go-delve/delve的调试命令?没错,我这里就是罗列了go-delve/delve的调试命令,额外增加了一个受gdb启发的ptype打印类型详情的命令。 > 写这本书的初衷是为了解释如何开发一款符号级调试器,而非为了写一个新的调试器,考虑到调试功能完整度、相关知识的覆盖度、工程的复杂度、个人时间有限等诸多因素,我最终采用了一种非常“开源”的方式,借鉴并裁剪了go-delve/delve中的代码,保留核心功能,删减与linux/amd64无关架构扩展代码,将rr (record and play)、dap(debugger adapter protocol)迁移至额外的阅读章节(可能放在附录页、扩展阅读)中进行介绍。 > > 这样作者可以保证在2022年让这本书完成初稿,以尽快与读者以电子书形式见面(纸质的也会考虑)。 ### 还需要注意什么 做一个产品需要注重用户体验,做一个调试器也一样,需要站在开发者角度考虑如何让开发者用的方便、调试的顺利。我们梳理了这么多需要支持的调试命令,正是关注产品体验的体现。 我们提供了很多的调试命令,功能上是够用的。但是一个基于命令行实现的调试器,调试命令越丰富反而可能更像是负担,因为实现命令输入并不是一件轻松的事情。 我们要特别注意以下几点: - 简化命令行输入,尤其是需要连续多次输入的情况; - 方便查看命令帮助,相关的命令合理分组,编写精简有用的帮助信息; - 方便观测多个变量,如执行期间观察、命中断点时观察、或者执行到某个defer函数时观察; - 保证健壮性,调试期间调试器崩溃、导致进程崩溃、发现DWARF数据、Go AST不兼容等等,导致无法顺利完成调试,应尽早发现并抛出问题,避免浪费开发者宝贵的时间。 #### 调试器的易用性 ##### 调试命令众多,需要降低记忆、使用成本 - 首先调试器有很多调试命令,如何记忆这些命令是有一定的学习成本的,而基于命令行的调试器会比基于GUI的调试器学习曲线更陡; - 基于命令行的调试器需考虑调试命令输入效率的问题,比如输入命令以及对应的参数。GUI调试器在源码某行处添加一个断点通常是很简单的事情,鼠标点一下即可,但基于命令行的调试器则需要用户显示提供一个源码位置,如"break main.go:15",或者"break main.main"; - 调试器诸多调试命令,需要考虑自动补全命令、自动补全参数,如果支持别名,将会是一个不错的选项。调试器还需要记忆上次刚使用过的调试命令,以方便重复使用,例如频繁地逐语句执行命令序列 ,可以通过命令序列 代替,回车键默认使用上次的命令,这样对用户来说更方便; - 每一个命令、命令参数都应该有明确的help信息,用户可以通过 `help cmd`来方便地查看命令cmd是做什么的,包含哪些选项、各个选项是做什么的。 ##### 命令行调试器,需要能同时显示多个观测值 - 基于命令行的调试器,其UI基于终端的文本模式进行显示,而非图形模式,这意味着它不能像GUI界面一样非常灵活方便地展示多种信息,如同时显示源码、断点、变量、寄存器、调用栈信息等; - 但是调试器也需要提供类似的能力,这样用户执行一条调试命令(如next、step)后能观测到多个变量、寄存器的状态。且在这个过程中,用户应该是不需要手动操作的。且多个观测变量、寄存器值的刷新动作耗时要短,要和执行next、step的耗时趋近。 #### 调试器的扩展性 ##### 命令、选项的扩展要有良好简洁的支持 - 调试器有多种启动方式,对应多个启动命令,如 `godbg exec `、`godbg debug `、`godbg attach `、`godbg core `,各自有不同的参数。此外调试器也有多种交互式的调试命令,如 `break `、`break cond `等,各自也有不同的参数。如何可扩展地管理这些命令及其选项是需要仔细考虑的; - 命令的选项,尽量遵循GNU/POSIX选项风格,这更符合大家的使用习惯,且选项在可以消除歧义的情况下尽量同时支持长选项、短选项,给开发输入时提供更多的便利; ##### 调试器应满足个性化定义以满足不同调试习惯 - 好的产品塑造用户习惯,但是更好的习惯应该只有用户自己知道,一个可配置化的调试器是比较合适的,如允许用户自定义命令的别名信息,等等; ##### 跨平台、支持不同调试后端、支持与IDE集成 - 调试器本身,可能需要考虑未来的应用情况,其是否具备足够的适应性以在各种应用场景中使用,如能否在GoLand、VSCode等IDE中使用,或者可能的远程调试场景等。这些也对调试器本身的软件架构设计提出了要求; - 应该考虑将来扩展到darwin/windows以及powerpc、i386等不同的系统、平台上,在软件设计时应提供必要的抽象设计,将抽象、实现分离; - 调试器实现不是万能的,存在这样的场景我们需要借助其他调试器实现,来完成某种功能,原因可能是我们的实现不支持被调试程序所在的系统、平台,或者其他调试器实现方法更优,举个例子,Mozillar rr(record and replay),记录重放的实现比较复杂,gdb、lldb、dlv的逆向调试基本上都是在rr基础上构建的。这就要求调试器要实现前后端分离式架构,而且后端部分接口与实现要分离,满足可替换,如能轻松地从dlv切换成Mozillar rr; #### 调试器的健壮性 - 调试器本身是依赖于一些操作系统能力的支持的,如Linux ptrace系统调用的支持,该系统调用的使用是有些约束条件的,比如ptrace_attach之后的tracee后续接收到的ptrace requests必须来自同一个tracer。还有syscall.Wait系统调用时Linux平台的一些特殊情况…这类情况有不少,调试器应该考虑到这些情况做兼容处理; - go调试器也依赖go编译工具链生成的一些调试信息,不同的go版本编译出的产物数据类型表示上、信号处理方面会有差异,调试器实现时应该考虑到这些情况做必要的处理,尽可能做到健壮。比如可以限制当前支持的go工具链版本,如果编译产物对应的go版本不匹配就放弃调试; 非功能性需求很多,我们从易用性,到命令管理的可维护性,到选型的规范性,到如何扩展到不同的操作系统、硬件平台、调试器后端实现,自身的健壮性等方面进行了描述。除了调试功能本身,这也是影响一个调试器能否被大家接受的很重要的因素。 ### 本节小结 本节实际上是对接下来的go符号级调试器demo tinydbg的功能型需求、非功能性需求进行了详细的一个分析,这个就是我们的一个目标了。OK,接下来我们进入这些调试命令的设计实现部分,Let's Go !!! ps: 本节中仍然接着指令级调试章节内容,将调试器命名为godbg,为了方便读者对指令级调试器demo、符号级调试器demo进行独立测试,我们后续的指令级调试器go module修改为tinydbg(而tinydbg裁剪自go-delve/delve)。这样读者可以方便地安装这两个调试器进行测试。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/01-debug-session.md ================================================ ## Debug Session ### 实现目标 后面的attach、exec、debug、core、connect,这几个命令本质上都是启动一个debugger backend,然后让debugger frontend和debugger backend建立连接的操作。 在建立连接之后,debugger frontend就需要建立一个调试会话了,调试会话中我们可以键入调试命令 `godbg> COMMAND [flags] [args]` 进行调试。 在第6章介绍指令级调试器时,我们已经介绍过如何实现一个调试会话了。这里,其实大同小异,尤其是我们对 go-delve/delve 进行了大幅简化之后: - 移除了分页输出操作,尽管它在输出大量数据时比较有用(比如print一个数据比较多的变量、bt打印一个比较深的调用栈、goroutines显示所有goroutines列表等); - 移除了starlark支持,尽管repl的方式交互式执行starlark命令、或者source执行starlark脚本很酷、能进一步增强调试器能力; - 移除了语法高亮支持,尽管list展示源码时、bt打印堆栈时、whatis查看类型定义时可以让可读性更好; OK,你们肯定知道我们移除的原因,我们想最大程度简化我们的demo tinydbg。其实对于上述每个特性,我们都在 “9.3 高级功能扩展中” 章节进行了介绍,但是不希望tinydbg中保留相关的代码,因为代码很复杂、读者容易淹没在代码洪流中。 这里简化后的调试会话,与第6章中的调试会话对比,有哪些区别呢?主要是实现了前后端分离式架构,然后前后端基于JSON-RPC进行通信,当我们在调试会话中执行一个调试命令时,调试会话会解析调试命令、选项、参数,然后会转换为对应的client方法调用,client本地方法桩代码会转化为对debugger backend的接口方法调用,debugger backend再执行对tracee的控制。我们主要是介绍这个部分的实现细节。 ### 基础知识 下图展示了 `godbg attach ` 启动一个一个调试器backend,以及在调试会话中执行命令 `godbg> COMMAND [flags] [args]` 过程中的详细交互过程、关键处理逻辑。 how debugsession works 这个时序图介绍了调试期间的两个重要步骤: - 第1部分启动一个调试器backend的操作,这部分将在介绍attach操作的实现时进行介绍,本节先不展开; - 第2部分调试会话中执行调试命令的操作,这是本小节我们要介绍的重点内容; 简单讲调试会话就是一个交互式调试窗口,允许你输入调试命令,并展示调试结果,如此反复直到结束调试。默认情况下调试会话就是一个交互式的命令行窗口,从stdin读取调试命令,然后在stdout、stderr输出调试结果。除非你想以非交互式的方式进行调试,如 `tinydbg debug --allow-non-terminal-interactive` 显示声明非交互式方式并设置正确的IO重定向。 ```bash tinydbg help debug Compiles your program with optimizations disabled, starts and attaches to it. By default, with no arguments, Delve will compile the 'main' package in the current directory, and begin to debug it. Alternatively you can specify a package name and Delve will compile that package instead, and begin a new debug session. Usage: tinydbg debug [package] [flags] ... Global Flags: --allow-non-terminal-interactive Allows interactive sessions of Delve that don't have a terminal as stdin, stdout and stderr -r, --redirect stringArray Specifies redirect rules for target process (see 'dlv help redirect') ... ``` OK,下面我们介绍下这块的调试会话初始化、输入调试命令进行调试的主流程。 ### 代码实现 #### 调试器前端初始化调试会话 什么情况下会启动一个调试会话呢? - 本地调试时总是会创建一个调试会话,不管是执行attach、debug、exec、core,此时调试器前端、后端位于同一个调试器进程中,它们通过net.Pipe进行通信; - 远程调试时调试器前后端分离,后端单独一个进程且没有控制终端,调试器前端通过connect命令连接到调试器后端,前后端通过net.TCPConn或者net.UnixConn进行通信。调试器前端会初始化一个调试会话,用户通过这个调试会话进行交互。 如果咱们是本地调试,执行的是attach命令,那么建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> attachCommand.Run() \--> attachCmd(...) \--> execute(pid, args, conf, "", debugger.ExecutingOther, args, buildFlags) ``` 在execute函数中,会根据是本地调试还是远程调试,来用不同的方法初始化RPC服务层: - 本地调试(未指定--headless):setup client/server communicate via preConnectedListener+net.Pipe - 远程调试(指定了--headless): setup client/server communicate via net.TCPListener+net.TCPConn or net.UnixListener+net.UnixConn 如果执行的是exec命令,那么建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> execCommand.Run() \--> execute(0, args, conf, "", debugger.ExecutingExistingFile, args, buildFlags) ``` 如果执行的是debug命令,那么建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> debugCommand.Run() \--> debugCmd(...) \--> execute(0, processArgs, conf, "", debugger.ExecutingGeneratedFile, dlvArgs, buildFlags) ``` 如果执行的是core命令,那么建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> coreCommand.Run() \--> coreCmd(...) \--> execute(0, []string{args[0]}, conf, args[1], debugger.ExecutingOther, args, buildFlags) ``` 如果咱们很明确就是远程调试,执行的是connect命令,那么建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> connectCommand.Run() \--> connectCmd(...) \--> connect(addr, nil, conf) ``` 这里讲的是调试器前后端如何连接起来,我们还需要看看调试前端如何输出 "godbg> " 以及如何解析命令、解析为本地client方法调用。 本地调试过程中的execute方法,和这里远程调试中的connect方法,都涉及到初始化调试会话的动作。 本地调试中execute方法最终会调用connect方法,只不过 `listener.Addr().String()=="" && clientConn != nil`,此时client就使用clientConn与net.Pipe另一端的server进行通信。而远程模式时, `listener.Addr().String() != "" && clientConn == nil`,此时client就会使用netDial(listener.Addr().String())新建连接并与server端通信。最后在connect最后,会完成调试会话的建立,并运行调试会话。 ```go func execute(attachPid int, processArgs []string, ...) int { ... var listener net.Listener var clientConn net.Conn // Make a TCP listener if headless { listener, _ = netListen(addr) } else { listener, clientConn = service.ListenerPipe() } defer listener.Close() ... return connect(listener.Addr().String(), clientConn, conf) } // 如果远程调试模式,则addr有效、clientConn无效,net.Dial即可 // 如果本地调试模式,则addr无效、clientConn有效,直接使用net.Pipe的一端clientConn即可 func connect(addr string, clientConn net.Conn, conf *config.Config) int { // Create and start a terminal - attach to running instance var client *rpc2.RPCClient if clientConn == nil { if clientConn = netDial(addr); clientConn == nil { return 1 // already logged } } client = rpc2.NewClientFromConn(clientConn) ... // 初始化调试会话 session := debug.New(client, conf) session.InitFile = initFile status, err := session.Run() if err != nil { fmt.Println(err) } return status } ``` 可以看到,在connect最后完成了调试会话的创建、运行: ```go session := debug.New(client, conf) status, err := session.Run() ``` 那调试会话是如何Run的呢?我们来看看session.Run()的更多细节。 ps: 篇幅原因,这里不介绍trace命令的执行流程,实际上跟其他几个也没多大区别,后面专门介绍trace命令时再介绍。 #### 调试会话如何运行的 这个方法 `debug.(*Session).Run()` 函数有点长,但是逻辑相对比较清晰: - 记录target定义的函数列表,方便后续在函数位置添加断点、执行到函数、创建tracepoint时,能够自动补全函数名; - 记录当前调试器支持的调试命令和别名,方便后续在输入命令时自动补全,并且输入命令参数时,能够做到自动补全参数 - 如果输入命令是break、continue、trace,则自动补全函数名 - 如果输入命令是nullcmd、nocmd,无特殊操作,此时是复用上一条调试命令; - 如果输入是print、whatis,则自动补全局部变量名; - 记录上一条执行的命令lastCmd; - 进入调试会话主循环,输入调试命令、执行调试命令,重点关注执行调试命令 `t.cmds.Call(cmdstr, t)`; 先来看看上述调试会话的函数: ```go // Run begins running the debugging session in the terminal. func (t *Session) Run() (int, error) { defer t.Close() multiClient := t.client.IsMulticlient() // Send the debugger a halt command on SIGINT ch := make(chan os.Signal, 1) signal.Notify(ch, syscall.SIGINT) go t.sigintGuard(ch, multiClient) // Record which functions are defined in the target fns := trie.New() // Record which debug commands and command aliases are supported by the current debugger cmds := trie.New() funcs, _ := t.client.ListFunctions("", 0) for _, fn := range funcs { fns.Add(fn, nil) } for _, cmd := range t.cmds.cmds { for _, alias := range cmd.aliases { cmds.Add(alias, nil) } } var locs *trie.Trie // Read current input, auto-complete command parameters based on input debug commands and incomplete command parameters t.line.SetCompleter(func(line string) (c []string) { cmd := t.cmds.Find(strings.Split(line, " ")[0], noPrefix) switch cmd.aliases[0] { // For breakpoint-related operations, complete function names case "break", "trace", "continue": if spc := strings.LastIndex(line, " "); spc > 0 { prefix := line[:spc] + " " funcs := fns.FuzzySearch(line[spc+1:]) for _, f := range funcs { c = append(c, prefix+f) } } // If no command is entered case "nullcmd", "nocmd": commands := cmds.FuzzySearch(strings.ToLower(line)) c = append(c, commands...) // If it's print or whatis, complete variable names case "print", "whatis": if locs == nil { localVars, err := t.client.ListLocalVariables( api.EvalScope{GoroutineID: -1, Frame: t.cmds.frame, DeferredCall: 0}, api.LoadConfig{}, ) if err != nil { fmt.Fprintf(os.Stderr, "Unable to get local variables: %s\n", err) break } locs = trie.New() for _, loc := range localVars { locs.Add(loc.Name, nil) } } if spc := strings.LastIndex(line, " "); spc > 0 { prefix := line[:spc] + " " locals := locs.FuzzySearch(line[spc+1:]) for _, l := range locals { c = append(c, prefix+l) } } } return }) // Read historical debug commands to quickly execute the previous command or repeat the last command via up/enter fullHistoryFile, err := config.GetConfigFilePath(historyFile) if err != nil { fmt.Printf("Unable to load history file: %v.", err) } t.historyFile, err = os.OpenFile(fullHistoryFile, os.O_RDWR|os.O_CREATE, 0600) if err != nil { fmt.Printf("Unable to open history file: %v. History will not be saved for this session.", err) } if _, err := t.line.ReadHistory(t.historyFile); err != nil { fmt.Printf("Unable to read history file %s: %v\n", fullHistoryFile, err) } fmt.Println("Type 'help' for list of commands.") if t.InitFile != "" { err := t.cmds.executeFile(t, t.InitFile) if err != nil { if _, ok := err.(ExitRequestError); ok { return t.handleExit() } fmt.Fprintf(os.Stderr, "Error executing init file: %s\n", err) } } // Record the last executed command var lastCmd string // Ensure that the target process is neither running nor recording by // making a blocking call. _, _ = t.client.GetState() // Enter the main loop of the debugger for { locs = nil // Read the user's input cmdstr, _ := t.promptForInput() if strings.TrimSpace(cmdstr) == "" { cmdstr = lastCmd } // Record the last executed command lastCmd = cmdstr // Execute the debugging command if err := t.cmds.Call(cmdstr, t); err != nil { if _, ok := err.(ExitRequestError); ok { return t.handleExit() } ... } } } ``` 再来看看执行命令的 `t.cmds.Call(cmdstr, t)`: ```go // Call takes a command to execute. func (s *DebugCommands) Call(cmdstr string, t *Session) error { ctx := callContext{Prefix: noPrefix, Scope: api.EvalScope{GoroutineID: -1, Frame: s.frame, DeferredCall: 0}} return s.CallWithContext(cmdstr, t, ctx) } // callContext represents the context of a command. type callContext struct { Prefix cmdPrefix Scope api.EvalScope Breakpoint *api.Breakpoint } type cmdfunc func(t *Session, ctx callContext, args string) error type command struct { aliases []string builtinAliases []string group commandGroup allowedPrefixes cmdPrefix helpMsg string cmdFn cmdfunc } type DebugCommands struct { cmds []*command client service.Client frame int // Current frame as set by frame/up/down commands. } // CallWithContext takes a command and a context that command should be executed in. func (s *DebugCommands) CallWithContext(cmdstr string, t *Session, ctx callContext) error { vals := strings.SplitN(strings.TrimSpace(cmdstr), " ", 2) cmdname := vals[0] var args string if len(vals) > 1 { args = strings.TrimSpace(vals[1]) } return s.Find(cmdname, ctx.Prefix).cmdFn(t, ctx, args) } ``` DebugCommands相当于是对调试会话中的调试命令的管理,这里的调试命令需要的参数,就没有attach、debug、exec、connect、core那么简单了,每个调试命令需要的参数有很大不同。spf13/corbra中command的执行函数 `spf13/cobra.(*Command).Run(fset *flagsset, args []string)`,如果还是使用flagset、args这俩固定参数,不是很够,为什么这么说呢?我们第6章指令级调试章节,不就是完全基于spf13/corbra Command管理机制实现的吗?我们接下来会解释。 ```go type command struct { aliases []string builtinAliases []string group commandGroup allowedPrefixes cmdPrefix helpMsg string cmdFn cmdfunc } ``` DebugCommands相当于自己维护所有的调试会话中的命令: 1. 每个命令的内置别名、自定义别名; 2. 每个命令的所属分组; 3. 每个命令允许的cmdprefix; 4. 每个命令的帮助信息; 5. 每个命令对应的执行函数; spf13/cobra的命令补全机制依赖生成的shell completion文件,spf13/cobra支持通过annotation机制来对命令分组、可以定制帮助信息,但是每个命令的执行函数还是固定只有`flagset *pflag.FlagSet`和`args []string`,如果函数中希望用到一些JSON-RPC client或者其他东西,就需要通过全局变量的形式来定义。但是到处读写全局变量的形式,对可读性和可维护性不好,还是希望函数签名能体现它依赖的对象。 OK,所以dlv这里是通过自定义的方式来对调试会话中的命令进行管理,当找到对应的调试命令后,就执行对应命令的 `cmdFn()`。所以每个调试命令的核心,是这个cmdFn内的实现逻辑,它往往涉及到对远程调试器backend的请求(拼接请求参数、序列化数据、网络交互、数据展示),涉及到的RPC请求可能也不止一个,比如`print varNam` 可能涉及到 `client.ListLocalVariables(...)`,`client.ExamineMemory(...)`, 等等。 OK,下面我们先看看JSON-RPC这里的代码逻辑,然后结合一个具体的例子看看。 #### 调试器前端发送 json-rpc请求给后端 这个小节我们重点得看几个代表性的调试命令的cmdFn的实现。 ```bash t.cmds.Call(cmdstr, t) \--> DebugCommands.CallWithContext(...) \--> cmd:= s.Find(cmdname, ctx.Prefix) \--> cmd.cmdFn(t, ctx, args) ``` 我们看下JSON-RPC client实现了哪些方法吧,然后选几个有代表性的进行介绍,不用在这里一一介绍。 我们以查看当前栈帧中的变量列表为例吧,ok,当我们执行 `godbg> vars [-v] []` ,此时会执行 `varsCmd.cmdFun(...) -> vars(...)`。 see path-to/tinydbg/cmds/debug/def.go,首先解析输入的命令行参数,filter就是要对变量列表进行过滤的一个正则表达式。然后请求t.client.ListPackageVariables(...)发起RPC调用,拿到服务端返回的变量列表后,将其打印出来。 ```go func vars(t *Session, ctx callContext, args string) error { // 解析 filter, cfg := parseVarArguments(args, t) vars, err := t.client.ListPackageVariables(filter, cfg) if err != nil { return err } return t.printFilteredVariables("vars", vars, filter, cfg) } ``` see path-to/tinydbg/service/rpc2/client.go,这部分就是发起JSON-RPC的逻辑,调用调试器后端的RPCServer.ListPackageVariables(...)这个方法,返回正则匹配的变量列表。 ```go func (c *RPCClient) ListPackageVariables(filter string, cfg api.LoadConfig) ([]api.Variable, error) { var out ListPackageVarsOut err := c.call("ListPackageVars", ListPackageVarsIn{filter, cfg}, &out) return out.Variables, err } // don't change this method name, it's used by main_test.go:TestTypecheckRPC func (c *RPCClient) call(method string, args, reply interface{}) error { return c.client.Call("RPCServer."+method, args, reply) } ``` 所有的JSON-RPC的请求、响应类型都定义在 `path-to/tinydbg/service/rpc2/*.go` 中,OK,接下来就是Go标准库中JSON-RPC实现的细节了: ```bash go/src/net/rpc.(*Client).Call(serverMethod, args, reply) error \--> rpc.(*Client).Go(serviceMethod, args, reply, donechan) *Call { \--> rpc.(*Client).send(call) \--> rpc.(*clientCodec).WriteRequest(request, call.Args) \--> rpc.(*Encoder).Encode(request) \--> e.marshal(v, encOpts) as JSON data \--> e.w.Write(jsondata), w==net.Conn ``` 发出去之后,调试器前端就等着调试器后端接受请求并处理、返回结果,那这里的JSON-RPC client是如何读取到结果返回的呢? 注意JSON-RPC client.Call这个方法的实现, `client.Go(serviceMethod, args, reply, ...)` 执行后会返回一个chan,这个chan里就放的是RPC的上下文的信息 `*rpc.Call`,这个call包含了request、request-id、response、error信息,当RPC执行结束,如超时、网络错误、或者收到回包的时候,就会将这个call放回这个chan并close掉,表示这个请求已经处理结束。此时 `<-client.Go(...).Done` 就会返回RPC上下文信息,这个函数最终返回有没有错误。 ```go // Call invokes the named function, waits for it to complete, and returns its error status. func (client *Client) Call(serviceMethod string, args any, reply any) error { call := <-client.Go(serviceMethod, args, reply, make(chan *Call, 1)).Done return call.Error } ``` 函数调用逐级返回,当到达下面这个函数时,就返回了服务器返回的变量列表: ```go func (c *RPCClient) ListPackageVariables(filter string, cfg api.LoadConfig) ([]api.Variable, error) { var out ListPackageVarsOut err := c.call("ListPackageVars", ListPackageVarsIn{filter, cfg}, &out) return out.Variables, err } ``` 然后,就可以打印出这些变量列表,显示给用户了: ```go func vars(t *Session, ctx callContext, args string) error { filter, cfg := parseVarArguments(args, t) vars, err := t.client.ListPackageVariables(filter, cfg) if err != nil { return err } return t.printFilteredVariables("vars", vars, filter, cfg) } ``` OK,那底层网络收包的细节是怎样的呢?和其他支持TCPConn、UnixConn全双工通信的网络编程框架类似,协议设计的时候请求、响应都要包含request-id,clientside记录一个`map[request-id]*rpc.Call`,等从服务端连接收到响应时,就从响应体力提取request-id,然后从上述map中找到原始的请求体,并将响应结果放回这个RPC上下文的内部`*rpc.Call.Reply`。 see go/src/net/rpc/client.go ```go // Call represents an active RPC. type Call struct { ServiceMethod string // The name of the service and method to call. Args any // The argument to the function (*struct). Reply any // The reply from the function (*struct). Error error // After completion, the error status. Done chan *Call // Receives *Call when Go is complete. } ``` see go/src/net/rpc/client.go,收回包的路径是这样的: ```bash go/src/net/rpc.(*Client).input() \--> forloop \--> client.codec.ReadResponseHeader(&response) \--> seq := response.Seq \--> call := client.pending[seq] delete(client.pending, seq) \--> if call == nil then this request timeout and deleted already \--> if reponse.Error != "" then set call.Error and done \--> call.Done <- call \--> if reponse.Error == nil then set call.Replay and done \--> call.Done <- call go/src/net/rpc.(*Client).Call(serviceMethod string, args any, reply any) error \--> call := <-client.Go(serviceMethod, args, reply, make(chan *Call, 1)).Done \--> return call.Err func (c *RPCClient) ListPackageVariables(filter string, cfg api.LoadConfig) ([]api.Variable, error) \--> err := c.call("ListPackageVars", ListPackageVarsIn{filter, cfg}, &out) \--> return out.Variables, err ``` OK,大致就是这样,如果你对更多细节感兴趣,可以自己看下这部分的源码。 #### 调试器后端初始化并接受请求 OK,接下来就是服务器侧收包并处理这些请求了,当我们以 `--headless` 模式启动时,我们会启动一个调试器backend,它以服务的形式运行。 see path-to/tinydbg/cmds/root.go ```go func execute(attachPid int, processArgs []string, conf *config.Config, coreFile string, kind debugger.ExecuteKind, dlvArgs []string, buildFlags string) int { ... // Make a TCP listener or Unix listener, or preConnectedListener via net.Pipe if headless { listener, err = netListen(addr) } else { listener, clientConn = service.ListenerPipe() } ... // Create and start a debugger server server := rpccommon.NewServer(&service.Config{ Listener: listener, ProcessArgs: processArgs, AcceptMulti: acceptMulti, Debugger: debugger.Config{ AttachPid: attachPid, WorkingDir: workingDir, CoreFile: coreFile, Foreground: headless && tty == "", Packages: dlvArgs, ... }, }) ... // run the server _ = server.Run() ... ``` 那么server.Run()具体做了什么呢? ```go // Run starts a debugger and exposes it with an JSON-RPC server. The debugger // itself can be stopped with the `detach` API. func (s *ServerImpl) Run() error { ... // Create and start the debugger config := s.config.Debugger if s.debugger, err = debugger.New(&config, s.config.ProcessArgs); err != nil { return err } // Register the RPC methods mapping, map[methodName] = methodHandler s.s2 = rpc2.NewServer(s.config, s.debugger) s.methodMap = make(map[string]*methodType) registerMethods(s.s2, s.methodMap) // Accept connection and serve the connection requests go func() { defer s.listener.Close() for { c, err := s.listener.Accept() if err != nil { select { case <-s.stopChan: // We were supposed to exit, do nothing and return return default: panic(err) } } // serve the connection requests go s.serveConnection(c) if !s.config.AcceptMulti { break } } }() return nil } func registerMethods(s *rpc2.RPCServer, methods map[string]*methodType) { methods["RPCServer.Command"] = &methodType{method: reflect.ValueOf(s.Command)} methods["RPCServer.CreateBreakpoint"] = &methodType{method: reflect.ValueOf(s.CreateBreakpoint)} methods["RPCServer.CreateWatchpoint"] = &methodType{method: reflect.ValueOf(s.CreateWatchpoint)} methods["RPCServer.Detach"] = &methodType{method: reflect.ValueOf(s.Detach)} methods["RPCServer.Disassemble"] = &methodType{method: reflect.ValueOf(s.Disassemble)} methods["RPCServer.Eval"] = &methodType{method: reflect.ValueOf(s.Eval)} methods["RPCServer.ExamineMemory"] = &methodType{method: reflect.ValueOf(s.ExamineMemory)} ... methods["RPCServer.ListLocalVars"] = &methodType{method: reflect.ValueOf(s.ListLocalVars)} methods["RPCServer.ListPackageVars"] = &methodType{method: reflect.ValueOf(s.ListPackageVars)} } ``` OK,看下如何处理连接请求的,JSON-RPC这里的serializer当然是JSON decoder了,这里从连接循环收包,收完一个request,就取出request.Method对应的handler `mtype`,这个handler就是一个方法了,然后就根据方法的入参类型、出参类型,通过反射将JSON中的数据decode成具体类型的字段值,然后通过反射调用对应的方法进行处理。处理完成后回包。 值得一提的是,这里的RPC接口,在C/S通信交互方式上既有同步接口,也有异步接口。所谓同步、异步,就是客户端是否必须等待服务器响应才能继续执行做后续处理。 - 对于客户端来说: - 同步接口的话,客户端会等服务器的处理结果,一般调用方法 `RPCClient.Go(...)`; - 而异步接口的话,客户端有可能不会等服务器的处理结果,比如disconnect操作通知下服务器就退出了,一般调用方法 `RPCClient.Call(...)`; - 对于服务端来说: - 同步接口,这种接口一般耗时比较短,或者客户端必须等待服务端处理完成,这类接口出参out的类型 != RPCCallback,而是具体的req对应的类型rsp; - 异步接口,这种接口一般耗时比较长,或者客户端不关心服务器是否处理完成,比如客户端exit时仅通知服务器即可、不用等到服务器detach完成,这类接口出参out的类型为RPCCallback,req对应的响应类型rsp,这里的rsp通过 `RPCCallback.Return(rsp)` 返回客户端; ps: 我们开发网络服务器时,主调服务有时也会有些通知被调服务的操作,仅做通知不等结果,或者被调服务处理完后再通知主调方。被调方也可能为了表示收到了请求而回包,但是请求的处理是异步的。对于后者,C/S通信交互方式上还是同步的,只是Server端请求处理上是异步处理的。所以有时候你说“异步”,是强调“异步通信”,还是“异步处理”,二字之差可能就会被挑战。如果没有其他上下文信息帮识别“异步”具体指的是通信方式异步,还是服务端处理方式异步,我们应该用词准确避免引发不必要的歧义。写东西写多了,才发现,语言的魅力除了“内涵文字”,“精确表达”才是它最大的魅力。 ```go func (s *ServerImpl) serveConnection(c io.ReadWriteCloser) { conn := &bufReadWriteCloser{bufio.NewReader(c), c} s.log.Debugf("serving JSON-RPC on new connection") go s.serveJSONCodec(conn) } func (s *ServerImpl) serveJSONCodec(conn io.ReadWriteCloser) { ... codec := jsonrpc.NewServerCodec(conn) var req rpc.Request var resp rpc.Response for { req = rpc.Request{} err := codec.ReadRequestHeader(&req) ... mtype, ok := s.methodMap[req.ServiceMethod] var argv, replyv reflect.Value ... // argv guaranteed to be a pointer now. if err = codec.ReadRequestBody(argv.Interface()); err != nil { return } ... if mtype.Synchronous { replyv = reflect.New(mtype.ReplyType.Elem()) function := mtype.method returnValues := function.Call([]reflect.Value{argv, replyv}) errInter := returnValues[0].Interface() ... resp = rpc.Response{} s.sendResponse(sending, &req, &resp, replyv.Interface(), codec, errmsg) ... } else { function := mtype.method ctl := &RPCCallback{s, sending, codec, req, make(chan struct{}), clientDisconnectChan} go func() { ... function.Call([]reflect.Value{argv, reflect.ValueOf(ctl)}) }() <-ctl.setupDone } } ... } ``` 以RPCServer.Command为例,这个Command操作是个典型的异步操作: ```go // Command interrupts, continues and steps through the program. func (s *RPCServer) Command(command api.DebuggerCommand, cb service.RPCCallback) { // 执行对应命令 st, err := s.debugger.Command(&command, cb.SetupDoneChan(), cb.DisconnectChan()) if err != nil { cb.Return(nil, err) return } var out CommandOut out.State = *st // 通过callback返回结果 cb.Return(out, nil) } ``` 举一个异步的调试命令disconnect作为参考,注意它和同步命令vars的不同。C/S异步通信交互方式用的是 `RPCClient.Go(...)`,同步通信方式用的是 `RPCClient.Call(...)`: ```go // disconnectCmd func (c *RPCClient) Disconnect(cont bool) error { if cont { out := new(CommandOut) // 异步处理的,并没有等待RPCServer.Command执行结束才返回 c.client.Go("RPCServer.Command", &api.DebuggerCommand{Name: api.Continue, ReturnInfoLoadConfig: c.retValLoadCfg}, &out, nil) } return c.client.Close() } // varsCmd func (c *RPCClient) ListPackageVariables(filter string, cfg api.LoadConfig) ([]api.Variable, error) { var out ListPackageVarsOut // call操作,等到收到处理结果后才返回 err := c.call("ListPackageVars", ListPackageVarsIn{filter, cfg}, &out) return out.Variables, err } ``` OK! 关于调试会话如何建立的,我们就介绍到这里。 ### 本节小结 这个小节我们对tinydbg调试器会话进行了非常详细的介绍,我们介绍了裁剪go-delve/delve过程中移除的一些特性,以让tinydbg尽可能保持代码精简,方便读者朋友们学习。我们介绍了tinydbg启动前后端以及调试会话工作期间,整个的一个交互时序,前后端的一些关键操作以及Linux内核介入的一些关键处理。然后,我们介绍了调试器会话中键入一个调试器命令开始,调试器前端如何解析并转入JSON—RPC发起对调试器后端RPC方法的调用、调试器后端的收包、处理、返回结果,我们甚至还介绍了标准库JSON-RPC的工作过程。 相信读者已经了解了调试器会话的具体工作过程,将来我们如果要扩展一个新的调试命令,大家应该了解我们需要对项目中哪些部分做调整了。OK,本节就先介绍到这里。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/100-debug_goroutines.md ================================================ go语言既然是面向协程的,我们的调试器就应该提供协程级的调试控制能力,比如切换到g1去执行并调试,或者切换到g2去执行并调试。这个功能在多线程调试中是很常见的,比如eclipse里面调试java多线程程序时,我们可以暂停其他线程只调试一个线程,或者允许某个线程跑起来并对其进行调试。gdb还提供了对fork后线程自动跟踪的能力(--follow-forked,忘记具体那个选项了)。 总而言之,调试go程序,如果只能实现多线程调试,其实还是比较鸡肋的,我们都清楚GPM调度模型中,一个M可能执行很多个协程,切换来切换去的,假如我们只能跟踪到线程级别,那我们其实还是比较难判断当前是跟踪的哪个协程的。 当然了,不是说完全不能调试,毕竟在单个线程上添加的断点,在这个线程执行g上的逻辑期间我们还是可以正常调试的,只是说,如果我们想切换到某个协程去运行时,则可能会比较困难,比如我只想让协程g2在恢复调度时立即停下来,我也不关心它停在哪里,这个时候goroutine goid就非常方便了。 要实现goroutine级别的控制能力,需要对go源码g理解比较透彻,至少要能区分不同的g(goid是如何存储的),g恢复执行后的下一条指令(g的协程上下文信息),blabla…… 我们要支持这个功能。 TODO 任务优先级:高 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/100-how_listfunctions_work.md ================================================ ## ListFunctions ### 实现目标 `ListFunctions`是tinydbg中的一个强大功能,它允许用户列出目标进程中定义的函数列表,也允许按照正则表达式的方式查询满足条件的函数列表。 `funcs ` 对应的核心逻辑即 ListFunctions,另外调试器命令 `tinydbg trace` 也依赖ListFunctions查找匹配的函数,然后在这些函数位置添加断点。 ### 基础知识 最主要的原因是获取函数的定义,这部分数据从哪里获取呢?从DWARF数据可以获取到,这个我们很早之前就介绍过了。这个并不困难,甚至支持按正则表达式检索也并不困难。 但是如果要递归地展开某个函数的函数调用图,这个就有点挑战了。联想下之前我们介绍过的 go-ftrace 的函数调用图,你就知道我们这个ListFunctions实现的挑战点在哪里了。 分析函数的调用图,大致有两种办法: 1、分析源码,构建AST,对FuncDecl.Body进行分析,找到所有函数调用类型的Expr,然后进行分析记录 …… 但是依赖源码进行trace这个比较不方便,最好依赖executable就可以搞定; 2、反汇编机器指令,找到所有的CALL 指令调用,找到对应的targetFuncName …… 这个确实构建出函数调用图了,但是如果要获取出入参信息,不好确定; 在2)基础上,为了更方便获取出入参,就要在程序启动时读取二进制文件的DWARF调试信息,将所有的函数定义记录下来,比如map[pc]Function,而Function就包含了name、pc、lowpc、highpc、length、dwarfregisters情况,我们已经知道了这个函数名对应的pc,便可以添加断点,当执行到断点处时,便可以执行pc处函数定义信息,比如知道如何获取函数的参数,就可以对应的规则将参数取出来。这样就实现了 `跟踪函数执行->打印函数名->打印函数参数列表+打印函数返回值列表` 的操作。 ### 代码实现 下面一起来这部分的关键代码逻辑。 #### 请求和响应参数类型 `ListFunctions` RPC调用接受两个参数: ```go type ListFunctionsIn struct { Filter string // 用于过滤函数名的正则表达式模式 FollowCalls int // 跟踪函数调用的深度(0表示不跟踪) } type ListFunctionsOut struct { Funcs []string // 匹配的函数名列表 } ``` #### 正则表达式过滤 函数名过滤使用正则表达式实现。当提供过滤模式时,它会被编译成正则表达式对象: ```go regex, err := regexp.Compile(filter) if err != nil { return nil, fmt.Errorf("invalid filter argument: %s", err.Error()) } ``` 这允许用户使用以下模式搜索函数: - `main.*` - 所有以"main"开头的函数 - `.*Handler` - 所有以"Handler"结尾的函数 - `[A-Z].*` - 所有导出的函数 #### 二进制信息读取 函数信息从目标二进制文件的调试信息(DWARF)中读取。这些信息在调试器初始化时加载并存储在`BinaryInfo`结构中。主要组件包括: - `Functions` 切片,包含二进制文件中的所有函数 - `Sources` 切片,包含所有源文件 - DWARF调试信息,用于详细的函数元数据 #### 函数信息提取 函数信息在调试器初始化期间从DWARF调试信息中提取。对于每个函数,存储以下信息: ```go type Function struct { Name string Entry, End uint64 // 函数地址范围 offset dwarf.Offset cu *compileUnit trampoline bool InlinedCalls []InlinedCall } ``` #### 获取函数列表 #### 函数调用遍历 当`FollowCalls`大于0时,调试器会执行函数调用的广度优先遍历。这是在`traverse`函数中实现的: ```go // Functions returns a list of functions in the target process. func (d *Debugger) Functions(filter string, followCalls int) ([]string, error) { d.targetMutex.Lock() defer d.targetMutex.Unlock() regex, err := regexp.Compile(filter) if err != nil { return nil, fmt.Errorf("invalid filter argument: %s", err.Error()) } funcs := []string{} t := proc.ValidTargets{Group: d.target} for t.Next() { for _, f := range t.BinInfo().Functions { if regex.MatchString(f.Name) { if followCalls > 0 { newfuncs, err := traverse(t, &f, 1, followCalls) if err != nil { return nil, fmt.Errorf("traverse failed with error %w", err) } funcs = append(funcs, newfuncs...) } else { funcs = append(funcs, f.Name) } } } } // uniq = sort + compact sort.Strings(funcs) funcs = slices.Compact(funcs) return funcs, nil } func traverse(t proc.ValidTargets, f *proc.Function, depth int, followCalls int) ([]string, error) { type TraceFunc struct { Func *proc.Function Depth int visited bool } // 使用map跟踪已访问的函数,避免循环 TraceMap := make(map[string]TraceFuncptr) queue := make([]TraceFuncptr, 0, 40) funcs := []string{} // 从根函数开始 rootnode := &TraceFunc{Func: f, Depth: depth, visited: false} TraceMap[f.Name] = rootnode queue = append(queue, rootnode) // BFS遍历 for len(queue) > 0 { parent := queue[0] queue = queue[1:] // 如果超过调用深度则跳过 if parent.Depth > followCalls { continue } // 如果已访问则跳过 if parent.visited { continue } funcs = append(funcs, parent.Func.Name) parent.visited = true // 反汇编函数以查找调用 text, err := proc.Disassemble(t.Memory(), nil, t.Breakpoints(), t.BinInfo(), f.Entry, f.End) if err != nil { return nil, err } // 处理每条指令 for _, instr := range text { if instr.IsCall() && instr.DestLoc != nil && instr.DestLoc.Fn != nil { cf := instr.DestLoc.Fn // 跳过大多数runtime函数,除了特定的几个 if (strings.HasPrefix(cf.Name, "runtime.") || strings.HasPrefix(cf.Name, "runtime/internal")) && cf.Name != "runtime.deferreturn" && cf.Name != "runtime.gorecover" && cf.Name != "runtime.gopanic" { continue } // 如果未访问过,将新函数添加到队列 if TraceMap[cf.Name] == nil { childnode := &TraceFunc{Func: cf, Depth: parent.Depth + 1, visited: false} TraceMap[cf.Name] = childnode queue = append(queue, childnode) } } } } return funcs, nil } ``` 遍历算法: 1. 使用map跟踪已访问的函数,避免重复访问 2. 使用队列进行广度优先遍历 3. 对于每个函数: - 反汇编其代码 - 查找所有CALL指令 - 提取被调用函数的信息 - 如果未访问过,将新函数添加到队列 4. 跳过大多数runtime函数以减少干扰 5. 遵守最大调用深度参数 ps: 这里为什么不使用AST呢?查找FuncDecl.Body中的所有函数调用,不也是一种办法,确实也是一种办法。但是通过AST的方式应该效率会很慢,而且由于存在内联,AST中的结构不一定能反映最终编译优化后的指令,比如内联优化。使用AST当我们尝试对某个函数位置进行trace并获取这个函数参数时,可能会出现错误,因为它被内联了,通过BP寄存器+参数偏移量的方式获取的不是真实参数。这里使用CALL指令可以避免上述考虑不周的错误,而且处理效率会更高效。 #### 结果处理 最后一步处理结果: ```go // 排序并删除重复项 sort.Strings(funcs) funcs = slices.Compact(funcs) ``` 这确保返回的函数列表: - 按字母顺序排序 - 没有重复项 - 只包含匹配过滤模式的函数 #### 使用场景 `ListFunctions`功能主要用于两个调试器命令: 1. `funcs ` - 列出所有匹配模式的函数 2. `trace ` - 在匹配的函数及其被调用函数上设置跟踪点 例如: ``` tinydbg> funcs main.* main.main main.init main.handleRequest tinydbg> trace main.* ``` trace命令使用`ListFunctions`并将`FollowCalls`设置为大于0,以查找可能被匹配函数调用的所有函数,从而实现全面的函数调用跟踪。 ### 本节小结 本文介绍了ListFunctions的设计实现,它通过正则表达式来对函数名进行过滤,并通过广度优先搜索+反汇编代码并分析CALL指令来查找函数的调用关系。相比使用AST分析,这种方式可以更好地应对内联优化带来的影响,这种方式相比分析源码也更加便利、高效。在tinydbg中ListFunctions主要服务于funcs和trace两个调试命令:1)funcs用于列出匹配模式的函数,2)trace用于在这些函数上设置跟踪点,并获取其参数。本文只讲述了如何ListFunctions,在 `tinydbg trace` 小节我们将进一步介绍如何获取跟踪到的函数的入参列表、返回值列表。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/100-multi-threads.md ================================================ 多线程调试的问题,前面已经提到过,对go程序而言,我们关注的是: - 区分go中哪些线程可以trace、哪些不可以trace - 可以trace的多个线程,如何自动trace - GPM模型中,如果因为ptrace挂起了一个线程,GPM会不会创建新的M - 。。。 TODO 任务优先级:高 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/11-tinydbg_attach.md ================================================ ## Attach ### 实现目标:`tinydbg attach ` 本节介绍attach这个启动调试的命令:`tinydbg attach [executable] [flags]`,attach操作将挂住目标进程、让目标进程停下来。 与指令级调试器godbg attach相比,这里的符号级调试器实现tinydbg attach增加了更多选项,来支持更加友好地调试。 ```bash $ tinydbg help attach Attach to an already running process and begin debugging it. This command will cause Delve to take control of an already running process, and begin a new debug session. When exiting the debug session you will have the option to let the process continue or kill it. Usage: tinydbg attach pid [executable] [flags] Flags: --continue Continue the debugged process on start. -h, --help help for attach --waitfor string Wait for a process with a name beginning with this prefix --waitfor-duration float Total time to wait for a process --waitfor-interval float Interval between checks of the process list, in millisecond (default 1) Global Flags: --accept-multiclient Allows a headless server to accept multiple client connections via JSON-RPC. --allow-non-terminal-interactive Allows interactive sessions of Delve that don't have a terminal as stdin, stdout and stderr --headless Run debug server only, in headless mode. Server will accept JSON-RPC client connections. --init string Init file, executed by the terminal client. -l, --listen string Debugging server listen address. Prefix with 'unix:' to use a unix domain socket. (default "127.0.0.1:0") --log Enable debugging server logging. --log-dest string Writes logs to the specified file or file descriptor (see 'dlv help log'). --log-output string Comma separated list of components that should produce debug output (see 'dlv help log') ``` 我们解释下上面的attach命令选项: 1. tinydbg attach pid,这个没有什么疑问,挂住正在执行的进程id==pid的进程; 2. --waitfor, --waitfor-duration, --waitfor-interval,等一个进程名前缀为xxx的进程起来、然后挂住,详见 [waitfor设计](./1-tinydbg_attach_waitfor.md)。 3. --accept-multiclient,这个是为了允许同一个debugger backend可以服务于多个debugger client,可以同时调试,或者先后调试,详见 [multiclient设计](../3-高级功能扩展/108-howto_accept_multiclient.md)。 4. --allow-non-terminal-interactive,如果您想在CI/CD或者自动化调试流程中执行上述非基于控制终端的调试; 5. --headless,启动一个debugger backend并作为服务器模式运行; 6. --listen,启动debugger backend并作为服务器模式运行,同时指定服务器的监听地址; 7. --log, --log-output,--log-dest, 是否启用服务端日志,启动哪些层次的日志,日志输出到哪里,详见 [日志系统设计](../1-架构设计/6-日志系统设计)。 OK,我们一起来看看Attach的详细设计实现。 ### 基础知识 在第六章指令级调试器开发过程中,我们结合Linux内核源码非常详细地介绍了syscall.PtraceAttach(...)的处理过程,如果您已经忘记了这里的细节,可以回去再看看相应内容。这里我们仅通过一张时序图对关键处理逻辑进行总结。 FIXME: 这个图有问题,参考第6章start+attach的put it together部分总结。

tinydbg attach

大致处理过程: - 用户在前端输入 `attach ` 命令。 - 前端通过 json-rpc(远程)或 net.Pipe(本地)将 attach 请求发送给后端。 - 后端解析请求,调用系统API(如 ptrace 或等效机制)附加到目标进程。 - 后端初始化调试上下文(如符号表、断点、线程信息等)。 - 返回 attach 结果,前端进入调试会话。 ### 代码实现 attach操作作为我们第一个介绍的调试命令实现,我们有必要在此详细地把调试器前后端交互的全流程介绍一遍,介绍后续调试命令的时候,我们就不会再这么重复地、详细地介绍了。所以请读者朋友们耐心。 #### Shell中执行 `tinydbg attach ` 首先,用户执行命令 `tinydbg attach `,tinydbg主程序是一个基于spf13/cobra的命令行程序: file: path-to/tinydbg/main.go ```go package main import ( ... "github.com/hitzhangjie/tinydbg/cmds" "github.com/hitzhangjie/tinydbg/pkg/logflags" "github.com/hitzhangjie/tinydbg/pkg/version" ) // Build is the git sha of this binaries build. // // note: we can pass -ldflags "-X main.Build=v0.0.1" to set the build version when building. var Build string = "v0.0.1" func main() { // current demo only supports linux/amd64 if runtime.GOOS != "linux" || runtime.GOARCH != "amd64" { fmt.Fprintf(os.Stderr, "WARNING: tinydbg only supports linux/amd64") os.Exit(1) } // this build version will be dumped when generating the core file if Build != "" { version.DelveVersion.Build = Build } // if cgo used, pass "-O0 -g" to disable optimization and enable debugging symbols const cgoCflagsEnv = "CGO_CFLAGS" if os.Getenv(cgoCflagsEnv) == "" { os.Setenv(cgoCflagsEnv, "-O0 -g") } else { logflags.WriteCgoFlagsWarning() } cmds.New().Execute() } ``` 既然是spf13/cobra管理的命令行程序,那它的子命令注册逻辑是大同小异的,它应该有个对应的attach subcmd。 see: path-to/tinydbg/cmds/cmd_root.go ```go // New returns an initialized command tree. func New() *cobra.Command { // Config setup and load. // // Delay reporting errors about configuration loading delayed until after the // server is started so that the "server listening at" message is always // the first thing emitted. Also, logflags hasn't been set up yet at this point. conf, loadConfErr = config.LoadConfig() buildFlagsDefault := "" // Main dlv root command. rootCommand = &cobra.Command{ Use: "tinydbg", Short: "tinydbg is a lightweight debugger trimmed from Delve (Dlv) for the Go programming language.", Long: longDesc, } ... rootCommand.AddCommand(attachCommand) rootCommand.AddCommand(connectCommand) ... } ``` 我们看到了这里的attachCommand的注册,当执行 `tinydbg attach` 时实际上执行的句式attachCommand.Run方法。 see: path-to/tinydbg/cmds/cmd_attach.go ```go // 'attach' subcommand. var attachCommand = &cobra.Command{ Use: "attach pid [executable]", Short: "Attach to running process and begin debugging.", Long: `Attach to an already running process and begin debugging it. This command will cause Delve to take control of an already running process, and begin a new debug session. When exiting the debug session you will have the option to let the process continue or kill it. `, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { if len(args) == 0 && attachWaitFor == "" { return errors.New("you must provide a PID") } return nil }, Run: attachCmd, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) != 1 { return nil, cobra.ShellCompDirectiveNoFileComp } return nil, cobra.ShellCompDirectiveDefault }, } ... func attachCmd(_ *cobra.Command, args []string) { var pid int if len(args) > 0 { var err error pid, err = strconv.Atoi(args[0]) if err != nil { fmt.Fprintf(os.Stderr, "Invalid pid: %s\n", args[0]) os.Exit(1) } args = args[1:] } os.Exit(execute(pid, args, conf, "", debugger.ExecutingOther, args, buildFlags)) } ``` 这里 `attachCommand.Run()` -> `attachCmd(...)` -> `execute(pid, args, conf, ....)` ,调试器前端用于前后端通信的RPC client在execute中进行初始化,并完成对server的调用。 ps:在9.1 架构设计中Service层设计时,我们提到过,对于本地调试其实是通过 preConnectedListener+net.Pipe 来模拟真实了网络连接通信过程。本质上还是按照C/S架构进行请求、处理的。 #### attach操作前后端扮演的职责 执行到execute方法时,才开始体现出前后端分离式架构下前后端的不同职责。对于本地调试模式,execute方法中既有初始化前端JSON-RPC client、调试会话的逻辑,也有初始化后端网络IO和debugger核心功能的逻辑。在远程调试模式下,execute方法主要是后端网络IO和debugger核心功能初始化,前端初始化要通过connect操作来完成,初始化JSON-RPC client和调试会话。 调试会话的初始化以及工作工程,我们前一节介绍过了。这个小节,我们来看下attach操作,不管是本地调试模式,还是远程调试模式,其实在已经介绍了调试会话的基础上,我们只需要关心attach操作涉及到的调试器后端的debugger核心功能初始化即可。 实际上,如果是远程调试模式,attach操作其实主要是调试器后端的操作,基本没前端什么事,attach操作并不需要前端发JSON-RPC请求,只需要后端做下网络IO初始化、debugger初始化,然后让debugger attach到目标进程就算结束了。如果是本地调试模式,在调试器后端完成上述初始化之后,调试器前端建立个调试会话就算准备就绪了,后续与后端的通信通过net.Pipe。 see: path-to/tinydbg/cmds/cmd_root.go ```go func execute(attachPid int, processArgs []string, conf *config.Config, coreFile string, kind debugger.ExecuteKind, dlvArgs []string, buildFlags string) int { ... var listener net.Listener var clientConn net.Conn if headless { listener, err = netListen(addr) // remote debug mode via TCP network } else { listener, clientConn = service.ListenerPipe() // local debug mode via net.Pipe } ... // debugger backend: create and start a debugger server server := rpccommon.NewServer(&service.Config{ Listener: listener, ProcessArgs: processArgs, AcceptMulti: acceptMulti, DisconnectChan: disconnectChan, Debugger: debugger.Config{ AttachPid: attachPid, 。。。 }, }) if err := server.Run(); err != nil { 。。。 } ... // debugger frontend: connect to debugger server and init debug session return connect(listener.Addr().String(), clientConn, conf) } ``` #### 调试器后端初始化并接受请求 调试器后端启动,网络初始化方式有两种方式: 1、一种是通过--headless模式启动,net.Listen创建一个TCPListener or UnixListener,然后等待入连接请求; 2、一种是本地模式启动,通过preConnectedListener+net.Pipe,来模拟网络监听、连接操作; see: path-to/tinydbg/cmds/cmd_root.go ```go func execute(attachPid int, processArgs []string, conf *config.Config, coreFile string, kind debugger.ExecuteKind, dlvArgs []string, buildFlags string) int { ... var listener net.Listener if headless { listener, err = netListen(addr) // remote debug mode via TCP network } else { listener, clientConn = service.ListenerPipe() // local debug mode via net.Pipe } ... // debugger backend: create and start a debugger server server := rpccommon.NewServer(&service.Config{ Listener: listener, ProcessArgs: processArgs, AcceptMulti: acceptMulti, DisconnectChan: disconnectChan, Debugger: debugger.Config{ AttachPid: attachPid, ... }, }) // debugger backend: run the server if err := server.Run(); err != nil { ... } ... } ``` 在 `server.Run()` 中开始执行后,会创建一个ptracer并attach到目标进程,然后会开始接受入连接请求并处理RPC请求。 ```go // Run starts a debugger and exposes it with an JSON-RPC server. The debugger // itself can be stopped with the `detach` API. func (s *ServerImpl) Run() error { var err error // Create and start the debugger config := s.config.Debugger if s.debugger, err = debugger.New(&config, s.config.ProcessArgs); err != nil { return err } s.s2 = rpc2.NewServer(s.config, s.debugger) s.methodMap = make(map[string]*methodType) // register RPC methods and relevant handlers registerMethods(s.s2, s.methodMap) // accept incoming connections and serves the RPC requests go func() { defer s.listener.Close() for { c, err := s.listener.Accept() ... go s.serveConnection(c) if !s.config.AcceptMulti { break } } }() return nil } ``` 那么,attach操作是什么时候执行的呢?调试器前端不是应该发送一个attach请求给调试器后台吗?理论上确实可以这么干,但是实际上没必要再多一轮RPC了,试想: 1、如果是远程调试,--headless启动server时我肯定知道要attach哪个tracee了,哪还需要客户端显示发RPC请求; 2、如果是本地调试,pid参数也已经通过命令行选项传递给进程了,本地调试中直接引用这个选项值即可,没必要再走一遍RPC; 确实如此,所以实际上是没有这个主动Attach的RPC的,不信你可以看看rpc2.Client的接口定义。 see: path-to/tinydbg/service/rpc2c/client.go #### 调试器后端attach到进程 OK,那紧接前面内容,调试器后端除了网络(listener、accept、serve)相关的初始化,还涉及到建立一个真正的调试器tracer来对目标进程tracee进行控制。其实attach操作就是在这个时候完成的。 see: path-to/tinydbg/service/debugger/debugger.go ```go package debugger // New creates a new Debugger. ProcessArgs specify the commandline arguments for the // new process. func New(config *Config, processArgs []string) (*Debugger, error) { ... d := &Debugger{ config: config, processArgs: processArgs, log: logger, } // Create the process by either attaching or launching. switch { case d.config.AttachPid > 0 || d.config.AttachWaitFor != "": d.log.Infof("attaching to pid %d", d.config.AttachPid) path := "" if len(d.processArgs) > 0 { path = d.processArgs[0] } var waitFor *proc.WaitFor if d.config.AttachWaitFor != "" { waitFor = &proc.WaitFor{ Name: d.config.AttachWaitFor, Interval: time.Duration(d.config.AttachWaitForInterval * float64(time.Millisecond)), Duration: time.Duration(d.config.AttachWaitForDuration * float64(time.Millisecond)), } } // attach the target tracee var err error d.target, err = d.Attach(d.config.AttachPid, path, waitFor) if err != nil { err = go11DecodeErrorCheck(err) err = noDebugErrorWarning(err) return nil, attachErrorMessage(d.config.AttachPid, err) } case d.config.CoreFile != "": ... default: ... } return d, nil } // Attach will attach to the process specified by 'pid'. func (d *Debugger) Attach(pid int, path string, waitFor *proc.WaitFor) (*proc.TargetGroup, error) { return native.Attach(pid, waitFor) } ``` 我们这里的debugger backend只有native一种实现,我们去掉了gdb、lldb、mozilla rr,你应该支持如果希望扩展应该在这里做做文章。 ```go // Attach to an existing process with the given PID. Once attached, if // the DWARF information cannot be found in the binary, Delve will look // for external debug files in the directories passed in. // // note: we remove the support of reading separate dwarfdata. func Attach(pid int, waitFor *proc.WaitFor) (*proc.TargetGroup, error) { // 如果指定了waitfor的方式attach,需要先等进程起来获取到pid if waitFor.Valid() { pid, err = WaitFor(waitFor) ... } dbp := newProcess(pid) // 执行实际的ptrace attach操作 var err error dbp.execPtraceFunc(func() { err = ptraceAttach(dbp.pid) }) ... // 执行wait操作获取tracee停止状态 _, _, err = dbp.wait(dbp.pid, 0) ... // tracee停下来后,这里尝试读取tracee的信息,并完成必要设置 tgt, err := dbp.initialize(findExecutable("", dbp.pid)) ... // ElfUpdateSharedObjects can only be done after we initialize because it // needs an initialized BinaryInfo object to work. err = linutil.ElfUpdateSharedObjects(dbp) ... return tgt, nil } ``` 到这里为止,调试器后端就已经与tracee正确建立了ptrace link关系了,后续前端就可以通过debug.Session发送交互式调试命令,debug.Session内部将其转换为对RPC调用debug.Session.client.Call(...)。 然后调试器后端就通过net.Conn不断接受请求,并按照JSON-RPC进行解码,根据请求参数,找到对应的服务端接口进行处理,接口内部又会调用debugger native中的各种操作来完成对目标进程的实际控制,并逐级返回结果,最终给到前端展示。 #### 调试器后端attach到进程后干了什么 see path-to/tinydbg/pkg/proc/native/proc.go ```go // initialize will ensure that all relevant information is loaded // so the process is ready to be debugged. func (dbp *nativeProcess) initialize(path string) (*proc.TargetGroup, error) { cmdline, err := dbp.initializeBasic() if err != nil { return nil, err } stopReason := proc.StopLaunched if !dbp.childProcess { stopReason = proc.StopAttached } procgrp := &processGroup{} grp, addTarget := proc.NewGroup(procgrp, proc.NewTargetGroupConfig{ DisableAsyncPreempt: false, StopReason: stopReason, CanDump: true, }) procgrp.addTarget = addTarget tgt, err := procgrp.add(dbp, dbp.pid, dbp.memthread, path, stopReason, cmdline) if err != nil { return nil, err } return grp, nil } func (dbp *nativeProcess) initializeBasic() (string, error) { cmdline, err := initialize(dbp) if err != nil { return "", err } if err := dbp.updateThreadList(); err != nil { return "", err } return cmdline, nil } ``` see path-to/tinydbg/pkg/proc/native/proc_linux.go ```go func initialize(dbp *nativeProcess) (string, error) { comm, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", dbp.pid)) if err == nil { // removes newline character comm = bytes.TrimSuffix(comm, []byte("\n")) } if comm == nil || len(comm) <= 0 { stat, err := os.ReadFile(fmt.Sprintf("/proc/%d/stat", dbp.pid)) if err != nil { return "", fmt.Errorf("could not read proc stat: %v", err) } expr := fmt.Sprintf("%d\\s*\\((.*)\\)", dbp.pid) rexp, err := regexp.Compile(expr) if err != nil { return "", fmt.Errorf("regexp compile error: %v", err) } match := rexp.FindSubmatch(stat) if match == nil { return "", fmt.Errorf("no match found using regexp '%s' in /proc/%d/stat", expr, dbp.pid) } comm = match[1] } dbp.os.comm = strings.ReplaceAll(string(comm), "%", "%%") return getCmdLine(dbp.pid), nil ... } func (dbp *nativeProcess) updateThreadList() error { tids, _ := filepath.Glob(fmt.Sprintf("/proc/%d/task/*", dbp.pid)) for _, tidpath := range tids { tidstr := filepath.Base(tidpath) tid, err := strconv.Atoi(tidstr) if err != nil { return err } if _, err := dbp.addThread(tid, tid != dbp.pid); err != nil { return err } } return linutil.ElfUpdateSharedObjects(dbp) } ``` 看上去attach到目标进程后,就开始读取目标进程的一些信息,包括pid、cmdline、exec、线程列表等等。 这里需要注意,进程内可能已有多线程被创建,也可能将来会创建新的线程。对于更友好地多线程调试,这些都要被管控起来: ```go const ( ptraceOptionsNormal = syscall.PTRACE_O_TRACECLONE ptraceOptionsFollowExec = syscall.PTRACE_O_TRACECLONE | syscall.PTRACE_O_TRACEVFORK | syscall.PTRACE_O_TRACEEXEC ) // Attach to a newly created thread, and store that thread in our list of // known threads. func (dbp *nativeProcess) addThread(tid int, attach bool) (*nativeThread, error) { if thread, ok := dbp.threads[tid]; ok { return thread, nil } ptraceOptions := ptraceOptionsNormal if dbp.followExec { ptraceOptions = ptraceOptionsFollowExec } var err error if attach { dbp.execPtraceFunc(func() { err = sys.PtraceAttach(tid) }) if err != nil && err != sys.EPERM { // Do not return err if err == EPERM, // we may already be tracing this thread due to // PTRACE_O_TRACECLONE. We will surely blow up later // if we truly don't have permissions. return nil, fmt.Errorf("could not attach to new thread %d %s", tid, err) } pid, status, err := dbp.waitFast(tid) if err != nil { return nil, err } if status.Exited() { return nil, fmt.Errorf("thread already exited %d", pid) } } dbp.execPtraceFunc(func() { err = syscall.PtraceSetOptions(tid, ptraceOptions) }) if err == syscall.ESRCH { if _, _, err = dbp.waitFast(tid); err != nil { return nil, fmt.Errorf("error while waiting after adding thread: %d %s", tid, err) } dbp.execPtraceFunc(func() { err = syscall.PtraceSetOptions(tid, ptraceOptions) }) if err == syscall.ESRCH { return nil, err } if err != nil { return nil, fmt.Errorf("could not set options for new traced thread %d %s", tid, err) } } dbp.threads[tid] = &nativeThread{ ID: tid, dbp: dbp, os: new(osSpecificDetails), } if dbp.memthread == nil { dbp.memthread = dbp.threads[tid] } for _, bp := range dbp.Breakpoints().M { if bp.WatchType != 0 { err := dbp.threads[tid].writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) if err != nil { return nil, err } } } return dbp.threads[tid], nil } ``` 这样就把目标进程中当前已有、将来可能会有的所有线程全部纳入到调试器管控逻辑中来了,调试器可以将它们作为一个组,控制它们全部执行或者暂停。 #### 接受入连接请求并处理请求 调试器后端收到入连接请求后,就开始对连接上的交互式调试请求进行处理:`s.serveConnection(c)`。这部分我们在前一节调试会话中,已经详细介绍过了,这里就不再赘述了。 至此,attach操作执行完成。如果是本地调试模式的话,通过前端提供的调试会话就直接可以开始交互式的调试了;如果是远程调试模式,则还需要通过connect操作与服务端建立连接、创建一个调试会话才能开始调试。 #### attach操作不涉及到RPC 对于attach操作,它是不涉及到前后端之间的JSON-RPC调用的,这个我们已经介绍过了,这里特别提一下。当你想查看attach操作的详细代码时,你可以搜索attachCmd,但是不要在rpc2/client.go中搜索响应的RPC方法,因为没有对应的RPC方法。 ### 执行测试 TODO: 1. 一开始就是单线程程序 2. 一开始就是多线程程序 3. 执行期间创建出新线程的程序 要观察他们是不是都attach住了,其中1、2都可以验证,3不行,因为attach后全部已有线程都暂停执行了。但是我们还没办法验证3、这点,因为我们还没有实现continue、disconnect等操作来恢复执行。 ### 本节小结 本文详细介绍了tinydbg的attach命令实现,主要包括以下几个方面: 1. attach命令的选项设计,包括基本的pid附加、waitfor等待进程、多客户端支持等高级特性; 2. attach的基础原理,通过时序图展示了从用户输入命令到最终完成进程附加的整个流程; 3. 代码实现细节: - 调试器前端如何解析和处理attach命令 - 调试器后端如何实现进程附加 - 如何处理目标进程的多线程情况 - 如何维护线程状态和硬件断点 4. 测试场景设计,包括单线程程序、多线程程序以及运行时创建新线程的程序的attach测试。 通过本文的学习,读者应该能够理解调试器是如何实现进程附加功能的,以及在实现过程中需要考虑的各种细节问题。后续章节将在此基础上继续介绍其他调试功能的实现。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/12-tinydbg_attach_waitfor.md ================================================ ## Attach WaitFor 工作原理 ### 简介 在调试进程时,我们经常需要等待目标进程启动后再附加调试器。`waitfor` 机制提供了一种灵活的方式来等待进程启动,它通过匹配进程名称前缀来实现。本文将详细解释这个功能在调试器中是如何工作的。 ```bash ```bash $ tinydbg help attach Attach to an already running process and begin debugging it. This command will cause Delve to take control of an already running process, and begin a new debug session. When exiting the debug session you will have the option to let the process continue or kill it. Usage: tinydbg attach pid [executable] [flags] Flags: --continue Continue the debugged process on start. -h, --help help for attach --waitfor string Wait for a process with a name beginning with this prefix --waitfor-duration float Total time to wait for a process --waitfor-interval float Interval between checks of the process list, in millisecond (default 1) ... ``` ### 为什么需要 WaitFor 在以下场景中,我们需要等待进程: 1. **进程启动时序**: - 调试时需要确保目标进程已经运行 - 直接附加到不存在的进程会导致失败 - WaitFor 确保只在进程就绪后才进行附加 2. **进程名称匹配**: - 有时我们只知道进程名称前缀,而不是具体的 PID - WaitFor 允许通过名称前缀匹配进程 - 这提供了更灵活的进程选择方式 3. **超时控制**: - 等待进程启动需要设置合理的超时时间 - WaitFor 提供了检查间隔和最大等待时间参数 - 这可以防止无限等待,并提供细粒度的控制 ### 实现细节 #### 核心数据结构 WaitFor 机制使用一个简单的结构体实现: ```go type WaitFor struct { Name string // 要匹配的进程名称前缀 Interval, Duration time.Duration // 检查间隔和最大等待时间 } ``` #### 主要实现 核心功能在 `native` 包中实现: ```go func WaitFor(waitFor *proc.WaitFor) (int, error) { t0 := time.Now() seen := make(map[int]struct{}) for (waitFor.Duration == 0) || (time.Since(t0) < waitFor.Duration) { pid, err := waitForSearchProcess(waitFor.Name, seen) if err != nil { return 0, err } if pid != 0 { return pid, nil } time.Sleep(waitFor.Interval) } return 0, errors.New("waitfor duration expired") } ``` #### 进程搜索实现 进程搜索通过以下步骤实现: 1. 遍历 `/proc` 目录查找匹配的进程 2. 读取进程的 `cmdline` 文件获取其名称 3. 使用 map 记录已检查过的进程 4. 通过名称前缀匹配进程 以下是实现的关键部分: ```go func waitForSearchProcess(pfx string, seen map[int]struct{}) (int, error) { des, err := os.ReadDir("/proc") if err != nil { return 0, nil } for _, de := range des { if !de.IsDir() { continue } name := de.Name() if !isProcDir(name) { continue } pid, _ := strconv.Atoi(name) if _, isseen := seen[pid]; isseen { continue } seen[pid] = struct{}{} buf, err := os.ReadFile(filepath.Join("/proc", name, "cmdline")) if err != nil { continue } // 将空字节转换为空格以便字符串比较 for i := range buf { if buf[i] == 0 { buf[i] = ' ' } } if strings.HasPrefix(string(buf), pfx) { return pid, nil } } return 0, nil } ``` #### 与调试器集成 WaitFor 机制集成到调试器的附加功能中: ```go func Attach(pid int, waitFor *proc.WaitFor) (*proc.TargetGroup, error) { if waitFor.Valid() { var err error pid, err = WaitFor(waitFor) if err != nil { return nil, err } } // ... 附加实现的其他部分 } ``` ### 命令行支持 调试器为 WaitFor 提供了几个命令行选项: - `--waitfor`:指定要等待的进程名称前缀 - `--waitfor-interval`:设置检查间隔(毫秒) - `--waitfor-duration`:设置最大等待时间 使用示例: ```bash ## 等待名为 "myapp" 的进程启动 debugger attach --waitfor myapp --waitfor-interval 100 --waitfor-duration 10 ``` ### 代码示例 以下是使用 WaitFor 的完整示例: ```go // 创建 WaitFor 配置 waitFor := &proc.WaitFor{ Name: "myapp", Interval: 100 * time.Millisecond, Duration: 10 * time.Second, } // 等待进程并附加 pid, err := native.WaitFor(waitFor) if err != nil { return err } // 附加到目标进程 target, err := native.Attach(pid, nil) if err != nil { return err } ``` ### 总结 WaitFor 机制为调试场景中的进程附加提供了可靠的方式。它确保我们只附加到实际运行的进程,并在如何识别目标进程方面提供了灵活性。该实现高效且与调试器的其他功能良好集成。 ### 参考资料 1. Linux `/proc` 文件系统文档 2. Go 标准库 `os` 包文档 3. 调试器源码 `pkg/proc/native` 包 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/13-tinydbg_exec.md ================================================ ## Exec ### 实现目标: `tinydbg exec ./prog` 本节介绍exec这个启动调试的命令:`tinydbg exec [executable] [flags]`,exec操作将执行executable对自动attach住对应的进程。在第6章介绍指令级调试器时,我们有演示如何通过exec.Command来指定要启动的程序、启动该程序以及如何在程序启动后自动被ptracer跟踪。如果忘记了这部分内容,可以回去看看6.1, 6.2, 6.3这几个小节。 demo tinydbg中的exec命令其实又是老调重弹,只不过这里tinydbg是前后端分离式架构,如果只考虑后端的target层对tracee的启动、控制部分,在需要注意的要点上是一样的。 ```bash $ tinydbg help exec Execute a precompiled binary and begin a debug session. This command will cause Delve to exec the binary and immediately attach to it to begin a new debug session. Please note that if the binary was not compiled with optimizations disabled, it may be difficult to properly debug it. Please consider compiling debugging binaries with -gcflags="all=-N -l" on Go 1.10 or later, -gcflags="-N -l" on earlier versions of Go. Usage: tinydbg exec [flags] Flags: --continue Continue the debugged process on start. -h, --help help for exec --tty string TTY to use for the target program Global Flags: --accept-multiclient Allows a headless server to accept multiple client connections via JSON-RPC. --allow-non-terminal-interactive Allows interactive sessions of Delve that don't have a terminal as stdin, stdout and stderr --disable-aslr Disables address space randomization --headless Run debug server only, in headless mode. Server will accept JSON-RPC client connections. --init string Init file, executed by the terminal client. -l, --listen string Debugging server listen address. Prefix with 'unix:' to use a unix domain socket. (default "127.0.0.1:0") --log Enable debugging server logging. --log-dest string Writes logs to the specified file or file descriptor (see 'dlv help log'). --log-output string Comma separated list of components that should produce debug output (see 'dlv help log') -r, --redirect stringArray Specifies redirect rules for target process (see 'dlv help redirect') --wd string Working directory for running the program. ``` exec操作的选项相比attach操作增加了一个 `--disable-aslr` ,我们只介绍下这个选项,其他选项我们介绍attach操作时都介绍过了,不重复描述。OK,第6章指令级调试器部分我们介绍过ASLR。这个特性大家一般很少会去用到,所以我们再提一下。 ASLR是一种操作系统级别的安全技术,主要作用是通过随机化程序在内存中的加载位置来增加攻击者预测目标地址、利用软件漏洞进行恶意操作的难度。其核心机制包括动态随机分配进程地址空间中各个部分的位置,例如executable基址、库文件、堆和栈等。Linux内核默认开启了完整的地址随机化,但是对于executale地址随机化必须要开启PIE编译模式。这虽然带来了一定安全性,但是如果你想做一些自动化调试的任务,而这些任务中使用指令地址进行了某些操作,那么ASLR可能会让调试失败。 所以这里增加了一个选项 `--disable-aslr`,这个选项会禁用上述提及的所有地址空间随机化能力。 ### 基础知识 ### 代码实现 主要代码执行路径如下: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> execCommand.Run() \--> execute(0, args, conf, "", debugger.ExecutingExistingFile, args, buildFlags) \--> server := rpccommon.NewServer(...) \--> server.Run() \--> debugger, _ := debugger.New(...) if attach 启动方式: debugger.Attach(...) elif core 启动方式:core.OpenCore(...) else 其他 debuger.Launch(...) \--> c, _ := listener.Accept() \--> serveConnection(conn) ``` 由于调试器后端初始化的逻辑我们之前都已经介绍过了,包括网络通信的初始化、debugger的初始化,这里我们直接看最核心的代码就好了。 对于exec启动方式的话,我们直接看debugger.Launch(...)的实现: ```go // Launch will start a process with the given args and working directory. func (d *Debugger) Launch(processArgs []string, wd string) (*proc.TargetGroup, error) { ... launchFlags := proc.LaunchFlags(0) if d.config.DisableASLR { launchFlags |= proc.LaunchDisableASLR } ... return native.Launch(processArgs, wd, launchFlags, d.config.TTY, d.config.Stdin, d.config.Stdout, d.config.Stderr) } func Launch(cmd []string, wd string, flags proc.LaunchFlags, tty string, stdinPath string, stdoutOR proc.OutputRedirect, stderrOR proc.OutputRedirect) (*proc.TargetGroup, error) { ... // 输入输出重定向设置 stdin, stdout, stderr, closefn, err := openRedirects(stdinPath, stdoutOR, stderrOR, foreground) if err != nil { return nil, err } ... dbp := newProcess(0) ... dbp.execPtraceFunc(func() { // 通过personality系统调用,禁用地址空间随机化 (只影响当前进程及其子进程) // 然后再启动我们的待调试程序,此时该程序就是禁用地址空间随机化的 if flags&proc.LaunchDisableASLR != 0 { oldPersonality, _, err := syscall.Syscall(sys.SYS_PERSONALITY, personalityGetPersonality, 0, 0) if err == syscall.Errno(0) { newPersonality := oldPersonality | _ADDR_NO_RANDOMIZE syscall.Syscall(sys.SYS_PERSONALITY, newPersonality, 0, 0) defer syscall.Syscall(sys.SYS_PERSONALITY, oldPersonality, 0, 0) } } // 启动待调试程序,此时该进程是被禁用了地址空间随机化的 process = exec.Command(cmd[0]) process.Args = cmd process.Stdin = stdin process.Stdout = stdout process.Stderr = stderr process.SysProcAttr = &syscall.SysProcAttr{ // Ptrace=true,go标准库中,子进程中会调用PTRACEME Ptrace: true, Setpgid: true, Foreground: foreground, } ... err = process.Start() }) // 等待tracee启动完成 dbp.pid = process.Process.Pid dbp.childProcess = true _, _, err = dbp.wait(process.Process.Pid, 0) // 进一步初始化,包括将tracee下的所有已有线程、未来可能创建的线程都纳入管控 tgt, err := dbp.initialize(cmd[0]) if err != nil { return nil, err } return tgt, nil } ``` see go/src/syscall/exec_linux.go ```go func forkAndExecInChild1(...) { ... if sys.Ptrace { _, _, err1 = RawSyscall(SYS_PTRACE, uintptr(PTRACE_TRACEME), 0, 0) if err1 != 0 { goto childerror } } ... ``` 这样exec操作,调试器后端的目标层逻辑就执行完成了。前后端网络IO初始化也完成之后,前端就可以通过调试会话发送调试命令了。 ### 执行测试 略 ### 本节小结 本文介绍了tinydbg exec命令的实现细节。exec命令用于启动一个新进程并对其进行调试,主要通过设置进程的SysProcAttr.Ptrace=true来实现。当新进程启动时,go运行时会自动调用PTRACE_TRACEME使子进程进入被跟踪状态。调试器等待子进程启动完成后,会将其所有线程纳入管控。这样就完成了exec操作的目标层逻辑,为后续的调试会话做好了准备。 另外我们也重新回顾了下ASLR的作用,以及对调试器调试的影响,介绍了下 `--disable-aslr` 的方法。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/14-tinydbg_debug.md ================================================ ## Debug ### 实现目标: `tinydbg debug ./path-to` attach操作是对一个已经运行的程序进行调试,或者--waitfor等待一个程序运行起来后进行调试。exec是对一个已经编译构建好的go可执行程序进行调试。debug则是对源代码的main package先进行编译,然后再执行类似exec的逻辑。go build这么简单的事情,为什么要多搞一个debug操作出来呢? 从实现上来说,debug确实没有比exec多出太多编码工作,它主要是简化大家的调试体验: 1)软件调试依赖调试信息生成,我们必须告知编译器生成调试信息,而且要对所有用到的modules; 2)编译器会对代码进行优化,如函数内联等,调试信息生成时如果没有照顾到这种情况,调试也会有问题,所以一般还会禁用内联; 通常需要这样指定编译选项,`go build -gcflags 'all=-N -l'` 这个命令是不是也没那么好敲? debug就是一个简化上述操作流的命令,我们一起来看下: ```bash $ tinydbg help debug Compiles your program with optimizations disabled, starts and attaches to it. By default, with no arguments, Delve will compile the 'main' package in the current directory, and begin to debug it. Alternatively you can specify a package name and Delve will compile that package instead, and begin a new debug session. Usage: tinydbg debug [package] [flags] Flags: --continue Continue the debugged process on start. -h, --help help for debug --output string Output path for the binary. --tty string TTY to use for the target program Global Flags: --accept-multiclient Allows a headless server to accept multiple client connections via JSON-RPC. --allow-non-terminal-interactive Allows interactive sessions of Delve that don't have a terminal as stdin, stdout and stderr --build-flags string Build flags, to be passed to the compiler. For example: --build-flags="-tags=integration -mod=vendor -cover -v" --disable-aslr Disables address space randomization --headless Run debug server only, in headless mode. Server will accept JSON-RPC client connections. --init string Init file, executed by the terminal client. -l, --listen string Debugging server listen address. Prefix with 'unix:' to use a unix domain socket. (default "127.0.0.1:0") --log Enable debugging server logging. --log-dest string Writes logs to the specified file or file descriptor (see 'dlv help log'). --log-output string Comma separated list of components that should produce debug output (see 'dlv help log') -r, --redirect stringArray Specifies redirect rules for target process (see 'dlv help redirect') --wd string Working directory for running the program. ``` 因为这里涉及到编译这个动作,`go build --tags=?` 支持对特定buildtag的源码进行编译,所以debug操作也需要增加一个选项 `--build-tags=` 来与之配合。其他选项我们前面都介绍过。 ### 基础知识 debug操作主要,主要就是为了保证编译时能够传递正确的编译选项,以保证编译器链接器能够生成DWARF调试信息,从而使我们顺利的进行调试。 没有其他特殊的。OK,我们来看下代码实现。 ### 代码实现 ```bash main.go:main.main \--> cmds.New(false).Execute() \--> debugCommand.Run() \--> debugCmd(...) \--> buildBinary \--> execute(0, processArgs, conf, "", debugger.ExecutingGeneratedFile, dlvArgs, buildFlags) \--> server := rpccommon.NewServer(...) \--> server.Run() \--> debugger, _ := debugger.New(...) if attach 启动方式: debugger.Attach(...) elif core 启动方式:core.OpenCore(...) else 其他 debuger.Launch(...) \--> c, _ := listener.Accept() \--> serveConnection(conn) ``` 构建可执行程序的操作如下,这个函数其实是支持对main module和test package执行构建的(isTest),只不过我们的demo tinydbg希望尽可能简化,而tinydbg debug、tinydbg test的不同之处也仅仅在此而已,所以我们demo tinydbg中移除了test命令。 ```go func buildBinary(cmd *cobra.Command, args []string, isTest bool) (string, bool) { // 确定构建产物的文件名: // main module,go build 产物为 __debug_bin // test package,用了go test -c的文件名方式 if isTest { debugname = gobuild.DefaultDebugBinaryPath("debug.test") } else { debugname = gobuild.DefaultDebugBinaryPath("__debug_bin") } // 执行构建操作 go build or go test -c, 带上合适的编译选项 err = gobuild.GoBuild(debugname, args, buildFlags) if err != nil { if outputFlag == "" { gobuild.Remove(debugname) } fmt.Fprintf(os.Stderr, "%v\n", err) return "", false } return debugname, true } // GoBuild builds non-test files in 'pkgs' with the specified 'buildflags' // and writes the output at 'debugname'. func GoBuild(debugname string, pkgs []string, buildflags string) error { args := goBuildArgs(debugname, pkgs, buildflags, false) return gocommandRun("build", args...) } ``` debug命令,在正常完成构建后,接下来和exec命令一样执行debugger.Launch(...),完成进程启动前的ALSR相关的设置、然后对Fork后子进程PTRACEME相关的设置,然后启动进程,进程启动后继续完成必要的初始化动作,如读取二进制文件的信息,通过ptrace设置将已经启动的线程和未来可能创建的线程全部管控起来。这里我们就这样简单总结一下,不详细展开了。 ### 执行测试 略 ### 本节小结 本文介绍了`tinydbg debug`命令的实现原理和使用方法。该命令的主要目的是简化Go程序的调试流程,通过自动添加必要的编译选项(如`-gcflags 'all=-N -l'`)来确保生成正确的调试信息、禁用内联优化。debug命令首先会编译源代码(如果有buildtags控制也支持通过 `--build-tags` 进行控制)然后执行类似exec的初始化逻辑,初始化debugger启动并attach到进程、管控进程下线程,以及初始化调试器的网络层通信。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/15-tinydbg_core1.md ================================================ ## Core (Part1): ELF核心转储文件剖析 可执行与可链接格式(ELF) 🧝 用于编译输出(`.o`文件)、可执行文件、共享库和核心转储文件。前几种用途在[System V ABI规范](http://www.sco.com/developers/devspecs/gabi41.pdf)和[工具接口标准(TIS) ELF规范](http://refspecs.linuxbase.org/elf/elf.pdf)中都有详细说明,但关于ELF格式在核心转储中的使用似乎没有太多文档。 我们接下来要介绍 `tinydbg core [executable] [corefile]` 对core文件进行调试,在这之前我们必须先了解下Core文件的事实上的规范,要记录些什么,按什么格式记录,如何兼容不同的调试器。理解了Core文件内容如何生成,也就理解了调试器读取Core文件时应该如何读取,才能重建问题现场。 这篇文章 [Anatomy of an ELF core file](https://www.gabriel.urdhr.fr/2015/05/29/core-file/) 中对Core文件的事实上的规范进行了梳理、总结,以下是摘录在这篇文章中的一些关于Core文件的说明。 ps: 本小节已经假定您已经阅读并理解了ELF文件的构成,这部分内容我们在第7章进行了介绍。另外,如果您想速览ELF文件相关内容给,也可以参考这篇文章 [knowledge about ELF files](https://www.gabriel.urdhr.fr/2015/09/28/elf-file-format/),介绍也非常详实。 OK,我们先创建一个core dump文件作为示例,方便结合着来介绍。 ```bash pid=$(pgrep xchat) gcore $pid readelf -a core.$pid ``` ### ELF header Core文件中ELF头部没有什么特别之处。`e_type=ET_CORE` 标记表明这是一个core文件: ```bash ELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian Version: 1 (current) OS/ABI: UNIX - System V ABI Version: 0 Type: CORE (Core file) Machine: Advanced Micro Devices X86-64 Version: 0x1 Entry point address: 0x0 Start of program headers: 64 (bytes into file) Start of section headers: 57666560 (bytes into file) Flags: 0x0 Size of this header: 64 (bytes) Size of program headers: 56 (bytes) Number of program headers: 344 Size of section headers: 64 (bytes) Number of section headers: 346 Section header string table index: 345 ``` ### Program headers Core文件中的段头表和可执行程序中的段头表,在某些字段含义上是有变化的,接下来会介绍。 ```bash Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align NOTE 0x0000000000004b80 0x0000000000000000 0x0000000000000000 0x0000000000009064 0x0000000000000000 R 1 LOAD 0x000000000000dbe4 0x0000000000400000 0x0000000000000000 0x0000000000000000 0x000000000009d000 R E 1 LOAD 0x000000000000dbe4 0x000000000069c000 0x0000000000000000 0x0000000000004000 0x0000000000004000 RW 1 LOAD 0x0000000000011be4 0x00000000006a0000 0x0000000000000000 0x0000000000004000 0x0000000000004000 RW 1 LOAD 0x0000000000015be4 0x0000000001872000 0x0000000000000000 0x0000000000ed4000 0x0000000000ed4000 RW 1 LOAD 0x0000000000ee9be4 0x00007f248c000000 0x0000000000000000 0x0000000000021000 0x0000000000021000 RW 1 LOAD 0x0000000000f0abe4 0x00007f2490885000 0x0000000000000000 0x000000000001c000 0x000000000001c000 R 1 LOAD 0x0000000000f26be4 0x00007f24908a1000 0x0000000000000000 0x000000000001c000 0x000000000001c000 R 1 LOAD 0x0000000000f42be4 0x00007f24908bd000 0x0000000000000000 0x00000000005f3000 0x00000000005f3000 R 1 LOAD 0x0000000001535be4 0x00007f2490eb0000 0x0000000000000000 0x0000000000000000 0x0000000000002000 R E 1 LOAD 0x0000000001535be4 0x00007f24910b1000 0x0000000000000000 0x0000000000001000 0x0000000000001000 R 1 LOAD 0x0000000001536be4 0x00007f24910b2000 0x0000000000000000 0x0000000000001000 0x0000000000001000 RW 1 LOAD 0x0000000001537be4 0x00007f24910b3000 0x0000000000000000 0x0000000000060000 0x0000000000060000 RW 1 LOAD 0x0000000001597be4 0x00007f2491114000 0x0000000000000000 0x0000000000800000 0x0000000000800000 RW 1 LOAD 0x0000000001d97be4 0x00007f2491914000 0x0000000000000000 0x0000000000000000 0x00000000001a8000 R E 1 LOAD 0x0000000001d97be4 0x00007f2491cbc000 0x0000000000000000 0x000000000000e000 0x000000000000e000 R 1 LOAD 0x0000000001da5be4 0x00007f2491cca000 0x0000000000000000 0x0000000000003000 0x0000000000003000 RW 1 LOAD 0x0000000001da8be4 0x00007f2491ccd000 0x0000000000000000 0x0000000000001000 0x0000000000001000 RW 1 LOAD 0x0000000001da9be4 0x00007f2491cd1000 0x0000000000000000 0x0000000000008000 0x0000000000008000 R 1 LOAD 0x0000000001db1be4 0x00007f2491cd9000 0x0000000000000000 0x000000000001c000 0x000000000001c000 R 1 [...] ``` 程序头中的`PT_LOAD`条目描述了进程的虚拟内存区域(VMAs): * `VirtAddr` 是VMA的起始虚拟地址; * `MemSiz` 是VMA在虚拟地址空间中的大小; * `Flags` 是这个VMA的权限(读、写、执行); * `Offset` 是对应数据在core dump文件中的偏移量。这 **不是** 在原始映射文件中的偏移量。 * `FileSiz` 是在这个core文件中对应数据的大小。与源文件内容相同的 “**只读文件**” 映射VMA不会在core文件中重复。它们的`FileSiz`为0,我们需要查看原始文件才能获得内容; * Non-Anonymous VMA关联的文件的名称和在该文件中的偏移量不在这里描述,而是在`PT_NOTE`段中描述(其内容将在后面介绍)。 由于这些是VMAs (vm_area),它们都按页边界对齐。 我们可以用 `cat /proc/$pid/maps` 进行比较,会发现相同的信息: ```bash 00400000-0049d000 r-xp 00000000 08:11 789936 /usr/bin/xchat 0069c000-006a0000 rw-p 0009c000 08:11 789936 /usr/bin/xchat 006a0000-006a4000 rw-p 00000000 00:00 0 01872000-02746000 rw-p 00000000 00:00 0 [heap] 7f248c000000-7f248c021000 rw-p 00000000 00:00 0 7f248c021000-7f2490000000 ---p 00000000 00:00 0 7f2490885000-7f24908a1000 r--p 00000000 08:11 1442232 /usr/share/icons/gnome/icon-theme.cache 7f24908a1000-7f24908bd000 r--p 00000000 08:11 1442232 /usr/share/icons/gnome/icon-theme.cache 7f24908bd000-7f2490eb0000 r--p 00000000 08:11 1313585 /usr/share/fonts/opentype/ipafont-gothic/ipag.ttf 7f2490eb0000-7f2490eb2000 r-xp 00000000 08:11 1195904 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 7f2490eb2000-7f24910b1000 ---p 00002000 08:11 1195904 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 7f24910b1000-7f24910b2000 r--p 00001000 08:11 1195904 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 7f24910b2000-7f24910b3000 rw-p 00002000 08:11 1195904 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 7f24910b3000-7f2491113000 rw-s 00000000 00:04 1409039 /SYSV00000000 (deleted) 7f2491113000-7f2491114000 ---p 00000000 00:00 0 7f2491114000-7f2491914000 rw-p 00000000 00:00 0 [stack:1957] [...] ``` core dump中的前三个 `PT_LOAD` 条目映射到`xchat`ELF文件的VMAs: * `00400000-0049d000`, 对应只读可执行段的VMA; * `0069c000-006a0000`, 对应读写段已初始化部分的VMA; * `006a0000-006a4000`, 读写段中不在`xchat` ELF文件中的部分(零初始化的`.bss`段)。 我们可以将其与`xchat`程序的程序头进行比较: ```bash Program Headers: Type Offset VirtAddr PhysAddr FileSiz MemSiz Flags Align PHDR 0x0000000000000040 0x0000000000400040 0x0000000000400040 0x00000000000001c0 0x00000000000001c0 R E 8 INTERP 0x0000000000000200 0x0000000000400200 0x0000000000400200 0x000000000000001c 0x000000000000001c R 1 [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000 0x000000000009c4b4 0x000000000009c4b4 R E 200000 LOAD 0x000000000009c4b8 0x000000000069c4b8 0x000000000069c4b8 0x0000000000002bc9 0x0000000000007920 RW 200000 DYNAMIC 0x000000000009c4d0 0x000000000069c4d0 0x000000000069c4d0 0x0000000000000360 0x0000000000000360 RW 8 NOTE 0x000000000000021c 0x000000000040021c 0x000000000040021c 0x0000000000000044 0x0000000000000044 R 4 GNU_EH_FRAME 0x0000000000086518 0x0000000000486518 0x0000000000486518 0x0000000000002e64 0x0000000000002e64 R 4 GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 0x0000000000000000 RW 10 Section to Segment mapping: Segment Sections... 00 01 .interp 02 .interp .note.ABI-tag .note.gnu.build-id .gnu.hash .dynsym .dynstr .gnu.version .gnu.version_d .gnu.version_r .rela.dyn .rela.plt .init .plt .text .fini .rodata .eh_frame_hdr .eh_frame 03 .init_array .fini_array .jcr .dynamic .got .got.plt .data .bss 04 .dynamic 05 .note.ABI-tag .note.gnu.build-id 06 .eh_frame_hdr 07 ``` ### Sections ELF核心转储文件通常不会包含节头表。Linux内核在生成核心转储文件时不会生成节头表。GDB会生成与程序头表信息相同的节头表: * `SHT_NOBITS` 类型的节在核心文件中不存在,但会引用其他已存在文件的部分内容; * `SHT_PROGBITS` 类型的节存在于核心文件中; * `SHT_NOTE` 类型的节头表映射到`PT_NOTE`程序头表。 ```bash Section Headers: [Nr] Name Type Address Offset Size EntSize Flags Link Info Align [ 0] NULL 0000000000000000 00000000 0000000000000000 0000000000000000 0 0 0 [ 1] note0 NOTE 0000000000000000 00004b80 0000000000009064 0000000000000000 A 0 0 1 [ 2] load NOBITS 0000000000400000 0000dbe4 000000000009d000 0000000000000000 AX 0 0 1 [ 3] load PROGBITS 000000000069c000 0000dbe4 0000000000004000 0000000000000000 WA 0 0 1 [ 4] load PROGBITS 00000000006a0000 00011be4 0000000000004000 0000000000000000 WA 0 0 1 [ 5] load PROGBITS 0000000001872000 00015be4 0000000000ed4000 0000000000000000 WA 0 0 1 [ 6] load PROGBITS 00007f248c000000 00ee9be4 0000000000021000 0000000000000000 WA 0 0 1 [ 7] load PROGBITS 00007f2490885000 00f0abe4 000000000001c000 0000000000000000 A 0 0 1 [ 8] load PROGBITS 00007f24908a1000 00f26be4 000000000001c000 0000000000000000 A 0 0 1 [ 9] load PROGBITS 00007f24908bd000 00f42be4 00000000005f3000 0000000000000000 A 0 0 1 [10] load NOBITS 00007f2490eb0000 01535be4 0000000000002000 0000000000000000 AX 0 0 1 [11] load PROGBITS 00007f24910b1000 01535be4 0000000000001000 0000000000000000 A 0 0 1 [12] load PROGBITS 00007f24910b2000 01536be4 0000000000001000 0000000000000000 WA 0 0 1 [13] load PROGBITS 00007f24910b3000 01537be4 0000000000060000 0000000000000000 WA 0 0 1 [...] [345] .shstrtab STRTAB 0000000000000000 036febe4 0000000000000016 0000000000000000 0 0 1 Key to Flags: W (write), A (alloc), X (execute), M (merge), S (strings), l (large) I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown) O (extra OS processing required) o (OS specific), p (processor specific ``` 注意,tinydbg中也不生成这里的节头表,只生成程序头表,因为借鉴相关的实现的时候,也是参考了Linux内核中的部分实现逻辑,而Linux内核生成Core文件时不生成sections。 ### Notes `PT_NOTE` 程序头记录了额外的信息,比如不同线程的CPU寄存器内容、与每个VMA关联的映射的文件等。它由这一系列的 [PT_NOTE entries](http://refspecs.linuxbase.org/elf/elf.pdf#page=42)组成,这些条目是[`ElfW(Nhdr)`](https://github.com/lattera/glibc/blob/895ef79e04a953cac1493863bcae29ad85657ee1/include/link.h#L351)结构(即`Elf32_Nhdr`或`Elf64_Nhdr`): * 发起者名称; * 发起者特定的ID(4字节值); * 二进制内容。 ```bash typedef struct elf32_note { Elf32_Word n_namesz; /* Name size */ Elf32_Word n_descsz; /* Content size */ Elf32_Word n_type; /* Content type */ } Elf32_Nhdr; typedef struct elf64_note { Elf64_Word n_namesz; /* Name size */ Elf64_Word n_descsz; /* Content size */ Elf64_Word n_type; /* Content type */ } Elf64_Nhdr; ``` 这些是notes中的内容: ```bash Displaying notes found at file offset 0x00004b80 with length 0x00009064: Owner Data size Description CORE 0x00000088 NT_PRPSINFO (prpsinfo structure) CORE 0x00000150 NT_PRSTATUS (prstatus structure) CORE 0x00000200 NT_FPREGSET (floating point registers) LINUX 0x00000440 NT_X86_XSTATE (x86 XSAVE extended state) CORE 0x00000080 NT_SIGINFO (siginfo_t data) CORE 0x00000150 NT_PRSTATUS (prstatus structure) CORE 0x00000200 NT_FPREGSET (floating point registers) LINUX 0x00000440 NT_X86_XSTATE (x86 XSAVE extended state) CORE 0x00000080 NT_SIGINFO (siginfo_t data) CORE 0x00000150 NT_PRSTATUS (prstatus structure) CORE 0x00000200 NT_FPREGSET (floating point registers) LINUX 0x00000440 NT_X86_XSTATE (x86 XSAVE extended state) CORE 0x00000080 NT_SIGINFO (siginfo_t data) CORE 0x00000150 NT_PRSTATUS (prstatus structure) CORE 0x00000200 NT_FPREGSET (floating point registers) LINUX 0x00000440 NT_X86_XSTATE (x86 XSAVE extended state) CORE 0x00000080 NT_SIGINFO (siginfo_t data) CORE 0x00000130 NT_AUXV (auxiliary vector) CORE 0x00006cee NT_FILE (mapped files) ``` 大多数数据结构(如`prpsinfo`、`prstatus`等)都定义在C语言头文件中(比如`linux/elfcore.h`)。 #### 通用进程信息 `CORE/NT_PRPSINFO` 条目定义了通用的进程信息,如进程状态、UID、GID、文件名和(部分)参数。 `CORE/NT_AUXV` 条目描述了[AUXV辅助向量](https://refspecs.linuxfoundation.org/LSB_1.3.0/IA64/spec/auxiliaryvector.html)。 #### 线程信息 每个线程都有以下条目: * `CORE/NT_PRSTATUS` (PID、PPID、通用寄存器内容等); * `CORE/NT_FPREGSET` (浮点寄存器内容); * `CORE/NT_X86_STATE`; * `CORE/SIGINFO`。 对于多线程进程,有两种处理方式: * 要么将所有线程信息放在同一个 `PT_NOTE` 中,此时消费者必须猜测每个条目属于哪个线程(实践中,一个 `NT_PRSTATUS` 定义了一个新线程); * 要么将每个线程放在单独的 `PT_NOTE` 中。 参见 [LLDB 源代码](https://github.com/llvm-mirror/lldb/blob/f7adf4b988da7bd5e13c99af60b6f030eb1beefe/source/Plugins/Process/elf-core/ProcessElfCore.cpp#L465) 中的说明: > 如果一个 core 文件包含多个线程上下文,则有两种数据形式 > > 1. 每个线程上下文(2个或更多NOTE条目)包含在其自己的段(PT_NOTE)中 > 2. 所有线程上下文存储在单个段(PT_NOTE)中。这种情况稍微复杂一些,因为在解析时我们必须找到新线程的起始位置。当前实现在找到 NT_PRSTATUS 或 NT_PRPSINFO NOTE 条目时标记新线程的开始。 在我们的 `tinydbg> dump [output]` 生成core文件时,是将多线程信息放在一个PT_NOTE中进行处理的。 #### 文件关联 `CORE/NT_FILE` 条目描述了虚拟内存区域(VMA)和文件之间的关联关系。每个非匿名VMA都有一个条目,包含: * VMA在虚拟地址空间中的位置(起始地址、结束地址); * VMA在文件中的偏移量(页偏移); * 关联的文件名。 ```bash Page size: 1 Start End Page Offset 0x0000000000400000 0x000000000049d000 0x0000000000000000 /usr/bin/xchat 0x000000000069c000 0x00000000006a0000 0x000000000009c000 /usr/bin/xchat 0x00007f2490885000 0x00007f24908a1000 0x0000000000000000 /usr/share/icons/gnome/icon-theme.cache 0x00007f24908a1000 0x00007f24908bd000 0x0000000000000000 /usr/share/icons/gnome/icon-theme.cache 0x00007f24908bd000 0x00007f2490eb0000 0x0000000000000000 /usr/share/fonts/opentype/ipafont-gothic/ipag.ttf 0x00007f2490eb0000 0x00007f2490eb2000 0x0000000000000000 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 0x00007f2490eb2000 0x00007f24910b1000 0x0000000000002000 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 0x00007f24910b1000 0x00007f24910b2000 0x0000000000001000 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 0x00007f24910b2000 0x00007f24910b3000 0x0000000000002000 /usr/lib/x86_64-linux-gnu/gconv/CP1252.so 0x00007f24910b3000 0x00007f2491113000 0x0000000000000000 /SYSV00000000 (deleted) 0x00007f2491914000 0x00007f2491abc000 0x0000000000000000 /usr/lib/x86_64-linux-gnu/libtcl8.6.so 0x00007f2491abc000 0x00007f2491cbc000 0x00000000001a8000 /usr/lib/x86_64-linux-gnu/libtcl8.6.so 0x00007f2491cbc000 0x00007f2491cca000 0x00000000001a8000 /usr/lib/x86_64-linux-gnu/libtcl8.6.so 0x00007f2491cca000 0x00007f2491ccd000 0x00000000001b6000 /usr/lib/x86_64-linux-gnu/libtcl8.6.so 0x00007f2491cd1000 0x00007f2491cd9000 0x0000000000000000 /usr/share/icons/hicolor/icon-theme.cache 0x00007f2491cd9000 0x00007f2491cf5000 0x0000000000000000 /usr/share/icons/oxygen/icon-theme.cache 0x00007f2491cf5000 0x00007f2491d11000 0x0000000000000000 /usr/share/icons/oxygen/icon-theme.cache 0x00007f2491d11000 0x00007f2491d1d000 0x0000000000000000 /usr/lib/xchat/plugins/tcl.so [...] ``` 据我所知(从binutils的`readelf`源码中了解到),`CORE/NT_FILE`条目的格式如下: 1. NT_FILE这样的映射条目的数量(32位或64位); 2. pagesize (GDB将其设为1而不是实际页大小,32位或64位); 3. 每个映射条目的格式: 1. 起始地址 2. 结束地址 3. 文件偏移量 4. 按顺序排列的每个路径字符串(以null结尾)。 #### 其他信息 自定义的调试工具也可以生成一些定制化的信息,比如可以读取环境变量信息,读取 `/proc//cmdline` 读取进程相关的启动参数,执行 `go version -m /proc//exe`,记录下其中的go buildid、vcs.branch、vcs.version,以及go编译器版本。将这些信息记录下来,这在拿到core文件进行离线分析时,这些信息也有助于确定找到匹配的构建产物、构建环境、代码版本,也有助于排查问题。 ### 本节小结 本文介绍了Linux系统中core dump文件的大致信息构成,并对core dump生成实践也进行了介绍,比如Linux内核、gdb、lldb调试器的做法,在了解了这些之后,我们可以开始介绍我们的tinydbg的调试会话命令 `tinydbg> dump [output]` 以及对core文件调试命令 `tinydbg core [executable] [core]` 了,继续吧。 ### 参考文献 * [Anatomy of an ELF core file](https://www.gabriel.urdhr.fr/2015/05/29/core-file/) * [A brief look into core dumps](https://uhlo.blogspot.com/2012/05/brief-look-into-core-dumps.html) * [linux/fs/binfmt_elf.c](https://elixir.bootlin.com/linux/v4.20.17/source/fs/binfmt_elf.c) * [The ELF file format](https://www.gabriel.urdhr.fr/2015/09/28/elf-file-format/) ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/15-tinydbg_core2.md ================================================ ## Core (Part2): 生成Core+调试Core ### 实现目标: `tinydbg core [corefile]` 本节我们介绍根据core文件进行调试 `tinydbg core [corefile]`,通常情况下core文件是程序异常终止或崩溃时操作系统为其生成的一个内存快照文件。它包含了程序崩溃时的信息,调试器利用它可以重建程序崩溃时的执行现场,帮助开发者定位问题。 利用core文件进行问题定位的一个最常见操作,就是执行命令 `bt`,可以定位程序崩溃时的堆栈,对于SIGMENTATION FAULT很容易定位。现在主流编程语言在程序出现异常或者严重错误时,都提供了栈回溯的能力,方便开发者查看问题堆栈。 比如: 1. Go语言支持对panic进行recover的同时,可以通过debug.Stack()来获取并打印协程堆栈信息;而环境变量GOTRACKBACK=crash就可以在崩溃时生成core文件; 2. Java语言可以通过Thread.dumpStack()或者Throwable.printStackTrace()打印当前线程的堆栈信息;JVM崩溃时会生成hs_err_pid*.log文件记录崩溃信息; 3. C++可以通过backtrace()、backtrace_symbols()等函数获取堆栈信息;通过设置ulimit -c unlimited开启core dump,程序崩溃时会生成core文件; Core文件本质上是进程某个时刻的快照信息,也不一定是崩溃时才生成,比如 `gcore ` 可以不挂掉进程的情况下生成core文件,当然肯定是想定位进程的一些问题时才会这么做,对于线上服务要踢掉流量后才能这么干,因为生成core文件过程中进程是暂停执行的。 ### 基础知识 #### core包含哪些信息 part1部分对core文件进行了详细介绍,这里还是简单回顾下。core文件是进程的一个内存快照文件,它包含了程序崩溃时的内存内容和寄存器状态等信息,主要有如下几部分: 1. ELF头信息:标识这是一个core文件,包含文件类型、机器架构等基本信息 2. 程序头表:描述了core文件中各个段的位置和属性 3. 内存映射段: - 包含程序的代码段、数据段、堆、栈等内存区域的内容 - 每个段都有对应的虚拟地址和访问权限信息 4. 寄存器状态: - 所有线程的通用寄存器值 - 浮点寄存器状态 - 特殊寄存器状态 5. 其他信息: - 进程ID、用户ID等进程信息 - 导致崩溃的信号信息 - 命令行参数和环境变量 - 打开的文件描述符信息 调试器可以读取core文件中的上述信息,然后重建程序崩溃时的执行现场,帮助开发者进行事后调试分析、问题复盘。 #### core文件如何生成 ### Linux下Core文件生成 #### Linux内核来生成 当程序收到某些特定信号(如SIGSEGV、SIGABRT等)时,如果系统开启了core dump功能,内核会帮助生成core文件。具体流程如下: 1. 触发core dump的常见信号: - SIGSEGV: 段错误,非法内存访问 - SIGABRT: 调用abort()函数 - SIGFPE: 浮点异常 - SIGILL: 非法指令 - SIGBUS: 总线错误 - SIGQUIT: 用户发送quit信号 2. 系统配置: ```bash # 检查是否开启core dump ulimit -c # 设置core文件大小限制(unlimited表示不限制) ulimit -c unlimited # 配置core文件路径格式 echo "/tmp/core-%e-%p-%t" > /proc/sys/kernel/core_pattern ``` 3. 内核处理流程: 1. 进程收到上述信号后,内核介入处理 2. 检查系统core dump配置是否允许生成core文件 3. 内核暂停进程所有线程 4. 收集进程内存映射、寄存器状态等信息 5. 将信息写入core文件 6. 终止进程 4. core文件命名规则(/proc/sys/kernel/core_pattern): - %p - 进程ID - %u - 用户ID - %g - 组ID - %s - 导致core dump的信号号 - %t - core dump的时间(UNIX时间戳) - %h - 主机名 - %e - 可执行文件名 所以生成core文件不需要调试器参与,这是由Linux内核提供的一个重要特性。调试器的作用是事后分析这个core文件,重建崩溃现场进行调试。 #### 自定义工具来生成 除了上述提到的哪些给进程发送信号、利用内核的能力来自动生成core文件以外,我们的自定义调试工具也可以自己实现这里的core文件转储的能力。 比如gdb软件包中的gcore,它也可以生成进程的core文件,而不用真的让进程挂掉。尽管大多数时候线上服务生成core文件是因为遇到了严重错误,但是实际上我们可以在不干掉进程的情况下生成它的core文件,实现其实也不复杂。 比如我们现在要生成某个进程的core文件,我们可以这么做: - 使用 `ptrace` 系统调用附加到目标进程; - 读取 `/proc//maps` 来了解内存布局; - 使用 `process_vm_readv()` 或通过 `ptrace(PTRACE_PEEKDATA, ...)` 读取内存区域; - 使用 `ptrace(PTRACE_GETREGS, ...)` 捕获寄存器状态; - 获取打开的文件、线程信息等; - 获取启动时的环境变量、启动参数、构建参数等; - ... - 将上述感兴趣的信息,按格式组织好后写入core文件。 OK,接下来我们就看看 tinydbg 中是如何生成core文件,并加载core文件的。 ### 代码实现 core文件生成其实是有调试会话的调试命令 `tinydbg> dump ` 来生成的,而加载core文件并启动调试是 `tinydbg core ` 来实现的。按照我们的目录安排,这一小节我们要先介绍core命令,然后再调试会话的命令部分,再介绍dump命令。但是core文件中数据的生产、消费是紧密相关的,生产、消费在章节安排上隔的很远,跳跃性太大、不易于读者理解学习。 所以我们先介绍dump命令如何实现core文件的生成,再介绍core文件的消费。 #### tinydbg生成core文件 ```bash $ (tinydbg) help dump Creates a core dump from the current process state dump The core dump is always written in ELF, even on systems (windows, macOS) where this is not customary. For environments other than linux/amd64 threads and registers are dumped in a format that only Delve can read back. ``` 生成core文件的核心代码路径: ```bash debug_other.go:debugCmd.cmdFn(...) \--> dump(s *Session, ctx callContext, args string) \--> dumpState, err := t.client.CoreDumpStart(args) \--> c.call("DumpStart", DumpStartIn{Destination: dest}, out) \--> forloop \--> print dumping progress \--> if !dumpState.Dumping { break } \--> else { dumpState = t.client.CoreDumpWait(1000)} \--> c.call("DumpWait", DumpWaitIn{Wait: msec}, out) } ``` 对于调试器后端来说很代码路径: ```bash tinydbg/service/rpc2.(*RPCServer).DumpStart(arg DumpStartIn, out *DumpStartOut) \--> s.debugger.DumpStart(arg.Destination) \--> (d *Debugger) DumpStart(dest string) error { \--> (t *Target) Dump(out elfwriter.WriteCloserSeeker, flags DumpFlags, state *DumpState) \--> 1. dump os/machine/abi... info as file header \--> 2. t.dumpMemory(state, w, mme): write mapped memory data \--> upadte DumpState.MemoryDone, DumpState.MemoryTotal \--> 3. prepare notes of dlv header, process, threads and other info \--> prepare note of dlv headerr: ... \--> prepare note of process: t.proc.DumpProcessNotes(notes, state.threadDone) \--> for each thread: \--> t.dumpThreadNotes(notes, state, th) \--> update DumpState.ThreadsDone, DumpState.ThreadsTotal \--> 4. w.WriteNotes(notes): dump dlv header, process info, threads info, and others as a new PT_NOTE type entry of ProgHeader table \--> out.State = *api.ConvertDumpState(s.debugger.DumpWait(0)) \--> return DumpState to rpc2.Client ``` 看下具体的源码实现,这里可以明确的是进程转储的过程是可能会花点时间的,不一定立马就完成,所以客户端请求DumpStart后服务器执行后会先返回一个DumpState,这个状态是当前的状态,不一定彻底完成了。如果没完成,客户端还会每隔1s再请求一次 `dumpState := t.client.CoreDumpWait(...)` 重新获取一次转储进度。 看完下面Dump的实现大家也会明白这里的转储进度是怎么算的,就两个指标,threads信息是否都转储完了,内存信息是否都转储完了,就这两部分可能会随进程工作负载情况耗时会久些。 ```go // DumpStart starts a core dump to arg.Destination. func (s *RPCServer) DumpStart(arg DumpStartIn, out *DumpStartOut) error { err := s.debugger.DumpStart(arg.Destination) if err != nil { return err } out.State = *api.ConvertDumpState(s.debugger.DumpWait(0)) return nil } // ConvertDumpState converts proc.DumpState to api.DumpState. func ConvertDumpState(dumpState *proc.DumpState) *DumpState { ... return &DumpState{ Dumping: dumpState.Dumping, AllDone: dumpState.AllDone, ThreadsDone: dumpState.ThreadsDone, ThreadsTotal: dumpState.ThreadsTotal, MemDone: dumpState.MemDone, MemTotal: dumpState.MemTotal, } } // DumpStart starts a core dump to dest. func (d *Debugger) DumpStart(dest string) error { ... fh, err := os.Create(dest) ... d.dumpState.Dumping = true d.dumpState.AllDone = false d.dumpState.Canceled = false d.dumpState.DoneChan = make(chan struct{}) d.dumpState.ThreadsDone = 0 d.dumpState.ThreadsTotal = 0 d.dumpState.MemDone = 0 d.dumpState.MemTotal = 0 d.dumpState.Err = nil go d.target.Selected.Dump(fh, 0, &d.dumpState) return nil } ``` 这里的selected实际上是TargetGroup中的某个Target,而Target指的是进程维度。如果是单进程程序TargetGroup中Target只有一个,如果是多进程程序,并且调试时 `tinydbg> target follow-exec [-on [regex]] [-off]` 打开了follow-exec模式。那么当创建子进程时如果子进程执行的命令命中正则表达式,就会自动将新创建的进程也给管理起来。此时TargetGroup就有不止一个Target。当然这里的Target层控制backend实现必须支持对父子进程进行控制,backend=native支持,对于gdb调试器也支持 `set follow-fork-mode child`。 对于多进程调试场景,又希望对父子进程同时进行暂停执行、恢复执行的情况,这里TargetGroup统一进行管理起来,就方便进行相应的暂停、恢复操作了。 ps:关于backend可扩展可替换的问题:在我们的demo tinydbg中,仅保留了dlv自己的实现native debugger,我们移除了支持gdb、lldb、mozilla rr等debugger backend的实现逻辑。注意,这里的术语backend指的不是前后端分离式架构中的调试器服务器,而是指的调试器服务器中的对于Target层进行控制的部分。中英文混用时,请读者注意分辨术语具体的含义。 OK,我们继续看 Target.Dump(...) 是如何实现的: ```go // Dump writes a core dump to out. State is updated as the core dump is written. func (t *Target) Dump(out elfwriter.WriteCloserSeeker, flags DumpFlags, state *DumpState) { defer func() { state.Dumping = false close(state.DoneChan) ... }() bi := t.BinInfo() // 1. write the ELF corefile header var fhdr elf.FileHeader fhdr.Class = elf.ELFCLASS64 fhdr.Data = elf.ELFDATA2LSB fhdr.Version = elf.EV_CURRENT fhdr.OSABI = elf.ELFOSABI_LINUX fhdr.Type = elf.ET_CORE fhdr.Machine = elf.EM_X86_64 fhdr.Entry = 0 w := elfwriter.New(out, &fhdr) ... // prepare notes of dlv header, process, threads and others notes := []elfwriter.Note{} // - note of dlv header entryPoint, _ := t.EntryPoint() notes = append(notes, elfwriter.Note{ Type: elfwriter.DelveHeaderNoteType, Name: "Delve Header", Data: []byte(fmt.Sprintf("%s/%s\n%s\n%s%d\n%s%#x\n", bi.GOOS, bi.Arch.Name, version.DelveVersion.String(), elfwriter.DelveHeaderTargetPidPrefix, t.pid, elfwriter.DelveHeaderEntryPointPrefix, entryPoint)), }) // - notes of threads state.setThreadsTotal(len(threads)) // note of process var threadsDone bool if flags&DumpPlatformIndependent == 0 { threadsDone, notes, _ = t.proc.DumpProcessNotes(notes, state.threadDone) } // notes of threads threads := t.ThreadList() if !threadsDone { for _, th := range threads { notes = t.dumpThreadNotes(notes, state, th) state.threadDone() } } // 2. write mapped memory data into corefile memmap, _ := t.proc.MemoryMap() memmapFilter := make([]MemoryMapEntry, 0, len(memmap)) memtot := uint64(0) for i := range memmap { if mme := &memmap[i]; t.shouldDumpMemory(mme) { memmapFilter = append(memmapFilter, *mme) memtot += mme.Size } } state.setMemTotal(memtot) for i := range memmapFilter { mme := &memmapFilter[i] t.dumpMemory(state, w, mme) } // 3. write these notes into corefile as a new entry of // ProgHeader table, with type `PT_NOTE`. notesProg := w.WriteNotes(notes) w.Progs = append(w.Progs, notesProg) w.WriteProgramHeaders() if w.Err != nil { state.setErr(fmt.Errorf("error writing to output file: %v", w.Err)) } state.Mutex.Lock() state.AllDone = true state.Mutex.Unlock() } ``` #### tinydbg加载core文件 加载Core文件的核心代码路径: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> coreCommand.Run() \--> coreCmd(...) \--> execute(0, []string{args[0]}, conf, args[1], debugger.ExecutingOther, args, buildFlags) \--> server := rpccommon.NewServer(...) \--> server.Run() \--> debugger, _ := debugger.New(...) if attach 启动方式: debugger.Attach(...) elif core 启动方式:core.OpenCore(...) else 其他 debuger.Launch(...) ``` 对于tinydbg core来说,就是core.OpenCore(...)这种方式。 ```go // OpenCore will open the core file and return a *proc.TargetGroup. // If the DWARF information cannot be found in the binary, Delve will look // for external debug files in the directories passed in. // // note: we remove the support of reading seprate dwarfdata. func OpenCore(corePath, exePath string) (*proc.TargetGroup, error) { p, currentThread, err := readLinuxOrPlatformIndependentCore(corePath, exePath) if err != nil { return nil, err } if currentThread == nil { return nil, ErrNoThreads } grp, addTarget := proc.NewGroup(p, proc.NewTargetGroupConfig{ DisableAsyncPreempt: false, CanDump: false, }) _, err = addTarget(p, p.pid, currentThread, exePath, proc.StopAttached, "") return grp, err } ``` 那读取core重建问题现场的核心逻辑,就在这里了: ```go // readLinuxOrPlatformIndependentCore reads a core file from corePath // corresponding to the executable at exePath. For details on the Linux ELF // core format, see: // https://www.gabriel.urdhr.fr/2015/05/29/core-file/, // https://uhlo.blogspot.com/2012/05/brief-look-into-core-dumps.html, // elf_core_dump in https://elixir.bootlin.com/linux/v4.20.17/source/fs/binfmt_elf.c, // and, if absolutely desperate, readelf.c from the binutils source. func readLinuxOrPlatformIndependentCore(corePath, exePath string) (*process, proc.Thread, error) { // read notes coreFile, _ := elf.Open(corePath) machineType := coreFile.Machine notes, platformIndependentDelveCore, err := readNotes(coreFile, machineType) ... // read executable exe, _ := os.Open(exePath) exeELF, _ := elf.NewFile(exe) ... // 1. build memory memory := buildMemory(coreFile, exeELF, exe, notes) // 2. build process bi := proc.NewBinaryInfo("linux", "amd64") entryPoint := findEntryPoint(notes, bi.Arch.PtrSize()) // saved in dlv header in PT_NOTE segment p := &process{ mem: memory, Threads: map[int]*thread{}, entryPoint: entryPoint, bi: bi, breakpoints: proc.NewBreakpointMap(), } if platformIndependentDelveCore { currentThread, err := threadsFromDelveNotes(p, notes) return p, currentThread, err } currentThread := linuxThreadsFromNotes(p, notes, machineType) return p, currentThread, nil } ``` 这里面最核心的两步就是建立起内存现场、进程状态现场。 前面没有详细介绍note的类型: ```go // Note is a note from the PT_NOTE prog. // Relevant types: // - NT_FILE: File mapping information, e.g. program text mappings. Desc is a LinuxNTFile. // - NT_PRPSINFO: Information about a process, including PID and signal. Desc is a LinuxPrPsInfo. // - NT_PRSTATUS: Information about a thread, including base registers, state, etc. Desc is a LinuxPrStatus. // - NT_FPREGSET (Not implemented): x87 floating point registers. // - NT_X86_XSTATE: Other registers, including AVX and such. type note struct { Type elf.NType Name string Desc interface{} // Decoded Desc from the } ``` ok继续看看buildMemory,这个函数主要分两步,对于PT_NOTE、PT_LOAD类型的分别进行处理: 1)PT_NOTE类型的程序头,其中类型为note.Type=_NT_FILE的note表示非匿名VMA区域映射的一些文件; Linux来生成Core文件的时候,会包含这些;tinydbg 内存区全部是PT_LOAD转储出去的。 2)PT_LOAD类型的程序头,读取的主要是可执行程序中的一些数据; ```go func buildMemory(core, exeELF *elf.File, exe io.ReaderAt, notes []*note) proc.MemoryReader { memory := &SplicedMemory{} // tinydbg没有生成note.Type=NT_FILE的notes信息, // // - 对于go程序而言,如果是内核生成的core文件,则会包含这个,详见linux `fill_files_notes` // - 对于tinydbg> debug my.core 而言,不会生成这部分信息 // // 这里假定所有的文件映射都来自exe,显然是不对的,比如共享库文件、外部其他文件就不是嘛 // - 1) 如果是只读文件,通常不会存储到core文件中(节省空间),此时需要从外部文件读 // 这里支持的不够!!! // 因为readNote函数里面只读取了VMA.start/end/offsetByPage,后面真正映射的文件名没有读取! // // - 2) 如果是可读写文件,通常会内核转储时转储这部分数据,应该以core文件数据为主, // 避免盲目读取外部文件数据造成覆盖 // // For now, assume all file mappings are to the exe. for _, note := range notes { if note.Type == _NT_FILE { fileNote := note.Desc.(*linuxNTFile) for _, entry := range fileNote.entries { r := &offsetReaderAt{ // why? 因为它假定了go大多数时候是静态编译,不使用共享库,也不涉及到mmap文件, // 那么内核生成coredump时基本就是这种情况。这里实现可以优化 reader: exe, offset: entry.Start - (entry.FileOfs * fileNote.PageSize), } memory.Add(r, entry.Start, entry.End-entry.Start) } } } // Load memory segments from exe and then from the core file, // allowing the corefile to overwrite previously loaded segments for _, elfFile := range []*elf.File{exeELF, core} { if elfFile == nil { continue } for _, prog := range elfFile.Progs { if prog.Type == elf.PT_LOAD { if prog.Filesz == 0 { continue } r := &offsetReaderAt{ reader: prog.ReaderAt, offset: prog.Vaddr, } memory.Add(r, prog.Vaddr, prog.Filesz) } } } return memory } ``` 注意对于NT_FILE类型的note,这种是内核创建Core文件时生成的,tinydbg中dump生成Core文件生成的都是PT_LOAD类型的,一股脑的将映射的内存全部以PT_LOAD的形式转储出来,省事。内核创建时会将非匿名映射VMA的关联文件信息以PT_NOTE的形式转储,并且里面的note.Type=NT_FILE。虽然,上述代码中假定所有的mapped files都来自executable是不完全对,但是即便如此,也不会影响调试准确性,因为这类note只是记录VMA与文件的映射关系,并不真的包含数据,数据还是要看这个PT_LOAD类型的部分。实际上已经读取的文件内容早就在进程地址空间中了,内核生成Core文件时记录了已映射数据在Core文件中的位置,所以可以知道已经映射的文件内容 …… 所以上面 `offsetReaderAt{reade.exe, ...}` 虽然写的看上去不太对,但是如果这些数据都已经通过PT_LOAD segments dump出来之后也就没问题了,读数据时是可以读到的。 但是有文章提到,说对于只读的PT_LOAD,其FileSZ==0 && MemSZ != 0,并且还是Non-Anonymous VMA区域,这时想拿到数据就得根据PT_NOTE表中的mapped file的filename来从外部存储读取,但是由于readNote处理时显示忽略了这些filenames,所以我认为在某些场景下tinydbg的调试会遇到问题。不过这不是本小节想一揽子解决的问题,大家理解即可。 ```go // readNote reads a single note from r, decoding the descriptor if possible. func readNote(r io.ReadSeeker, machineType elf.Machine) (*note, error) { // Notes are laid out as described in the SysV ABI: // https://www.sco.com/developers/gabi/latest/ch5.pheader.html#note_section note := ¬e{} hdr := &elfNotesHdr{} err := binary.Read(r, binary.LittleEndian, hdr) note.Type = elf.NType(hdr.Type) name := make([]byte, hdr.Namesz) note.Name = string(name) desc := make([]byte, hdr.Descsz) descReader := bytes.NewReader(desc) switch note.Type { case elf.NT_PRSTATUS: note.Desc = &linuxPrStatusAMD64{} case elf.NT_PRPSINFO: note.Desc = &linuxPrPsInfo{} binary.Read(descReader, binary.LittleEndian, note.Desc) case _NT_FILE: // No good documentation reference, but the structure is // simply a header, including entry count, followed by that // many entries, and then the file name of each entry, // null-delimited. Not reading the names here. data := &linuxNTFile{} binary.Read(descReader, binary.LittleEndian, &data.linuxNTFileHdr) for i := 0; i < int(data.Count); i++ { entry := &linuxNTFileEntry{} binary.Read(descReader, binary.LittleEndian, entry) data.entries = append(data.entries, entry) } note.Desc = data case _NT_X86_XSTATE: if machineType == _EM_X86_64 { var fpregs amd64util.AMD64Xstate amd64util.AMD64XstateRead(desc, true, &fpregs, 0) note.Desc = &fpregs } case _NT_AUXV, elfwriter.DelveHeaderNoteType, elfwriter.DelveThreadNodeType: note.Desc = desc } skipPadding(r, 4) return note, nil } ``` 另外,参考内核源码中 `fill_files_note(struct memelfnote *note)` 的实现,这个函数展示了NT_FILE note的数据格式,我们可以知道long start, long end, long file_ofs都是VMA中的位置,而不是mapped files中的位置。所以前面也说只要mapped files的内容,除了在PT_NOTE中的映射关系,即使我们不读取文件名,只要这些数据被dump到了core文件PT_LOAD segments中,我们从core文件buildMemory后,建立了SplicedMemory,这里面包含了进程coredump时所有的VMA区域,只要这些mapped files的数据被记录到了core文件中,后续读内存时实际上就是从这个SplicedMemory中读取,是可以读取到的,没有必要读取外部文件。但是前提是,转储出来了(FileSZ != 0)。 实际上,尽管进程执行时可能mapped file对应的VMA是只读,但是在文件系统上不一定是,还是可能会被修改,那调试时从外部文件读取不就完蛋了吗。所以我认为,为了方便调试,还是应该把这部分数据转储到core中来,虽然core文件会大点。但是应该也不那么在乎这点磁盘占用把 >ps: 进程的完整地址空间,所有的这些VMAs都不会被转储到core文件中。但是有些VMAs是没有建立物理内存映射的,这部分在记录到core文件中时只会记录一些必要信息,没有实际数据,也不会写0值,但是文件中确实留下了一些空洞。这种情况下 `ls -h` 会显示文件偏大,但是 `du -hs` 会显示更小些。我在做游戏服务器开发时,观察到战斗服进程Core文件尺寸 `ls` 显示高达80GB,但是实际上`du`显示只有800MB+左右。 ```c /* * Format of NT_FILE note: * * long count -- how many files are mapped * long page_size -- units for file_ofs * array of [COUNT] elements of * long start * long end * long file_ofs * followed by COUNT filenames in ASCII: "FILE1" NUL "FILE2" NUL... */ static int fill_files_note(struct memelfnote *note) { struct vm_area_struct *vma; unsigned count, size, names_ofs, remaining, n; user_long_t *data; user_long_t *start_end_ofs; char *name_base, *name_curpos; /* *Estimated* file count and total data size needed */ count = current->mm->map_count; size = count * 64; names_ofs = (2 + 3 * count) * sizeof(data[0]); alloc: size = round_up(size, PAGE_SIZE); data = kvmalloc(size, GFP_KERNEL); start_end_ofs = data + 2; name_base = name_curpos = ((char *)data) + names_ofs; remaining = size - names_ofs; count = 0; for (vma = current->mm->mmap; vma != NULL; vma = vma->vm_next) { struct file *file; const char *filename; file = vma->vm_file; filename = file_path(file, name_curpos, remaining); /* file_path() fills at the end, move name down */ /* n = strlen(filename) + 1: */ n = (name_curpos + remaining) - filename; remaining = filename - name_curpos; memmove(name_curpos, filename, n); name_curpos += n; *start_end_ofs++ = vma->vm_start; *start_end_ofs++ = vma->vm_end; *start_end_ofs++ = vma->vm_pgoff; count++; } /* Now we know exact count of files, can store it */ data[0] = count; data[1] = PAGE_SIZE; ... size = name_curpos - (char *)data; fill_note(note, "CORE", NT_FILE, size, data); return 0; } ``` #### 后续读取内存操作 注意到从core文件buildMemory过程中追踪进程coredump时的内存映射情况: ```go type SplicedMemory struct { readers []readerEntry } func buildMemory(core, exeELF *elf.File, exe io.ReaderAt, notes []*note) proc.MemoryReader { memory := &SplicedMemory{} // For now, assume all file mappings are to the exe. for _, note := range notes { if note.Type == _NT_FILE { fileNote := note.Desc.(*linuxNTFile) for _, entry := range fileNote.entries { r := &offsetReaderAt{ reader: exe, offset: entry.Start - (entry.FileOfs * fileNote.PageSize), } memory.Add(r, entry.Start, entry.End-entry.Start) } } } // Load memory segments from exe and then from the core file, // allowing the corefile to overwrite previously loaded segments for _, elfFile := range []*elf.File{exeELF, core} { if elfFile == nil { continue } for _, prog := range elfFile.Progs { if prog.Type == elf.PT_LOAD { if prog.Filesz == 0 { continue } r := &offsetReaderAt{ reader: prog.ReaderAt, offset: prog.Vaddr, } memory.Add(r, prog.Vaddr, prog.Filesz) } } } return memory } ``` 我们重点关注下半部分的readers构建情况: ``` for _, prog := range elfFile.Progs { if prog.Type == elf.PT_LOAD { if prog.Filesz == 0 { continue } r := &offsetReaderAt{ reader: prog.ReaderAt, offset: prog.Vaddr, } memory.Add(r, prog.Vaddr, prog.Filesz) } } ``` 我们只处理有映射,并且FileSZ !=0 的部分,如果FileSZ==0,索性直接不处理了(联想下我们readNote时也没有记录下文件名,也没法读取,实际上读取了,由于这些文件本身可能内容变了,对我们也没什么用)。然后就将这些有数据的内存给放到咱们的SplicedMemory中,每个VMA都对应着这样的一个reader: ```go r := &offsetReaderAt{ reader: prog.ReaderAt, offset: prog.Vaddr, } ``` 后续当我们需要读取内存时,就不是像调试进程那样通过ptrace(PTRACE_PEEKTEXT/PEEKDATA, ...)那样读取了,而是直接从这里的SplicedMemory中的readers中读取: 1、先根据要读取的起始地址、数据量确定大约在哪些VMAs对应的readers中; 2、然后从这些readers中读取; 3、这里的每个reader要读取的数据的起始地址都已经记录好了,起始地址起始就是Core文件中每个PT_LOAD类型的VirtSize。 ps: part1部分我们提到过,在可执行程序中,VirtSize表示PT_LOAD类型在进程地址空间中的加载地址,但是在Core文件中,它表示在Core文件中的偏移量。 #### 后续读取寄存器操作 这个自然就更简单了,这些信息都记录在了PT_NOTE对一个的segment里,我们读取时就已经解析好了,并放置到了合适的数据结构里,自然不是什么问题。 #### 唯一美中不足的是 唯一美中不足的是,有些FileSZ==0的非匿名mapped file对应的VMA,这部分数据可能内核没有写出,而这些mapped file在事后又被修改了。即使我们读取回来也和当时问题现场不一致。这个是个现实问题。 tinydbg,没有处理这些mapped file的读取,而是直接选择性忽略了。因为即使它支持读取,其实也没法善后处理这些真实存在的问题。 tinydbg做到现在这样,及很好了,see discussion here: https://github.com/go-delve/delve/discussions/4031。 #### 后续初始化及调试 之后,调试器继续初始化完调试会话、网络通信部分,就可以基于core文件查看问题现场,并尝试定位问题了。 ### 执行测试 即使打开了core文件,也只是读了一份快照,虽然重建了问题现场,但是并不是重建了进程,所以调试会话中的涉及到执行类的调试命令都是没法执行的。core文件调试,通常使用bt观察堆栈、frame选择栈帧并通过locals、args来查看函数参数、局部变量信息。 测试示例,略。 ### 本节小结 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/16-tinydbg_connect.md ================================================ ## Connect ### 实现目标: `tinydbg connect ` 在远程调试模式下,connect命令用来连接一个调试器后端,完成网络通信层的初始化,然后初始化一个前端调试会话,开发者即可交互式地进行调试了。 ```bash $ tinydbg help connect Connect to a running headless debug server with a terminal client. Prefix with 'unix:' to use a unix domain socket. Usage: tinydbg connect [flags] Flags: -h, --help help for connect Global Flags: --init string Init file, executed by the terminal client. --log Enable debugging server logging. --log-dest string Writes logs to the specified file or file descriptor (see 'dlv help log'). --log-output string Comma separated list of components that should produce debug output (see 'dlv help log') ``` ### 基础知识 相比attach、exec、debug (or test)、core这几个调试命令,connect是彻彻底底的为远程调试准备的。既然是远程调试,就涉及到调试器前端、后端独立运行。 调试器后端运行,可以通过attach、exec、debug(or test)、core,并配合参数 `--headless` 参数就可以启动一个调试器后端,它等待调试前端通过TCPConn或UnixConn以JSON RPC或者DAP RPC的形式进行通信。在我们的demo tinydbg中,我们只支持JSON-RPC进行通信。关于DAP (Debugger Adapater Protocol),我们在 "3-高级功能扩展" 小节进行介绍。 调试器后端运行时,允许通过参数 `-l | --listen` 来指定一个监听地址: ```bash -l, --listen string Debugging server listen address. Prefix with 'unix:' to use a unix domain socket. (default "127.0.0.1:0") ``` - default:127.0.0.1:0,port没有指定的情况下,会自动分配一个port,调试器进程会打印出监听地址,以方便调试器前端连接; 与VSCode集成后为了更方便地进行调试,就需要前后端能够就监听地址达成一致,以方便VSCode调试器前端连接; - 指定具体的 IP:PORT,如果提前规划好了使用某个IP:PORT用于RPC通信,也可以指定IP:PORT; - 指定 unix:/path-to/socket,也可以使用Unix Domain Socket进行通信; 如果考虑到VSCode远程开发、容器开发以及WebIDE远程开发,那我们还得掰扯掰扯VSCode的C/S分离式架构,以及插件运行方式(extensionKind,在UI/Local Extension Host、Remote/Workspace Extension Host、或二者均可)。如果咱们有时间的话,就分享下这些内容,以及VSCode(C/S)、VSCode调试器插件(local/remote extension host)、调试器前后端(C/S)它们之间是如何进行交互的。 OK,先言归正传,我们先介绍下connect命令的代码实现。 ### 代码实现 前面调试器会话小节,我们提到过connect的大致实现方式,这里再简单回顾一遍吧,建立调试会话的代码路径是: ```bash main.go:main.main \--> cmds.New(false).Execute() \--> connectCommand.Run() \--> connectCmd(...) \--> connect(addr, nil, conf) \--> conn := netDial(addr) \--> if isTCPAddress, conn, _ := net.Dial("tcp", addr) \--> if isUnixAddress, conn, _ := net.Dial("unix", addr) \--> client := rpc2.NewClientFromConn(conn) \--> session := debug.New(client, conf) \--> session.Run() \--> forloop \--> read input \--> parse debugcmd flags args \--> session.client.Call('RPCServer.'+method, req, rsp) \--> json-rpc over tcpconn or unixconn \--> update UI based on rsp ``` 执行connect命令,大致会经历上述代码路径,connect会根据传递的参数addr来确定是一个tcp监听地址,还是一个unix domain socket,然后建立对应的连接。一旦连接建立了,就可以初始化rpcclient。然后初始化一个调试会话,调试会话运行起来后就是一个类似repl的forloop,读取输入,解析命令、参数、选项,然后执行。只不过这里的执行,需要与调试器服务器交互,而且几乎所有的调试命令都如此。调试器会话与调试器服务器之间通过建立的通信链路完成请求发送、响应接受。然后根据响应,调试器前端更新显示,如显示变量值、指令列表、打印类型详情、显示当前程序执行到的指令地址及源码位置,等等。 调试器会话初始化、网络通信层的初始化过程,以及后续调试器前端与调试器后端的详细交互过程,我们都已经在调试会话小节已经详细介绍了,这里就不再赘述了。 值得一提的是,调试器后端启动调试时如果指定了 `--accept-multiclient` 那么才允许调试器后端执行期间接受多个入客户端连接请求: - 客户端1正在调试,此时客户端2来连接; - 客户端2已经结束调试,并且已经与调试服务器分离,但是没有杀死进程实例,此时客户端来连接; 这两种情况,如果想允许客户端2来连接,都需要在启动调试器后端时显示指定上述选项 `--accept-multiclient`。那么为什么不默认启用选项 `--accept-multiclient` 呢? 对于常见的 `tinydbg debug ...` 操作来说,因为程序是我们自动构建出来的,也是自己启动的进程,所以调试完后默认预期是这个进程已经被调试利用完了,没有继续存在的必要了,所以会提示调试人员是否需要自动kill该进程,绝大多数情况下,大家会点“是”。这才是绝大多数情况。而对于前一次调试完了,后面又发起一次调试,但是这种情况下,说明一时半会确定不了问题,需要多次调试跟踪,此时在有明确诉求的情况下,直接加选项 `--accept-multicilent` 后启动即可。另外,如果我们加了这个选项,在我们调试期间,如果真的有人连接进来了,它执行的一些调试动作可能会影响到我们。但是,允许多个客户端同时登录也增加了一定的灵活性,如这样可能允许多人联合调试、联合定位异常。 ### 执行测试 略 ### 本节小结 本节介绍了connect命令的实现,它允许调试器前端连接到独立运行的调试器后端进程。我们详细讲解了连接建立的过程、调试会话的初始化,以及多客户端连接支持的相关考虑。这为理解分布式调试场景下调试器的工作方式提供了基础。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/17-tinydbg_trace.md ================================================ ## Trace ### 实现目标 trace命令用于对go进程中的特定函数调用进行跟踪,适合性能分析、异常检测和安全审计等场景。 本节介绍 `trace` 命令的实现,它能够对某个package下的函数名匹配regexp的函数进行跟踪,并且支持对函数体内fanout出去的函数调用自动进行跟踪。在某些场景下希望检查特定函数是否有被执行、执行代码路径如何以及执行耗时如何,trace命令就会非常有用。 ```bash $ tinydbg help trace Trace program execution. The trace sub command will set a tracepoint on every function matching the provided regular expression and output information when tracepoint is hit. This is useful if you do not want to begin an entire debug session, but merely want to know what functions your process is executing. The output of the trace sub command is printed to stderr, so if you would like to only see the output of the trace operations you can redirect stdout. Usage: tinydbg trace [flags] Flags: -e, --exec string Binary file to exec and trace. --follow-calls int Trace all children of the function to the required depth. -h, --help help for trace -p, --pid int Pid to attach to. -s, --stack int Show stack trace with given depth. --timestamp Show timestamp in the output. Global Flags: --disable-aslr Disables address space randomization --log Enable debugging server logging. --log-dest string Writes logs to the specified file or file descriptor (see 'dlv help log'). --log-output string Comma separated list of components that should produce debug output (see 'dlv help log') -r, --redirect stringArray Specifies redirect rules for target process (see 'dlv help redirect') --wd string Working directory for running the program. ``` 和dlv相比,我们移除了对package、源文件进行构建并测试的功能,仅保留核心功能逻辑,下面介绍下仍支持的选项: - --pid,跟踪已经在运行的进程,不能搭配--disable-aslr使用 - --exec,启动并跟踪一个可执行程序,可配合--disable-aslr使用 - --follow-calls,跟踪函数调用时限制函数体fanout函数调用的深度 - --stack,trace命令在regexp匹配的各个函数名的入口地址、返回地址都设置了断点,每次执行到这里时,打印堆栈 ### 基础知识 对函数调用进行跟踪,有两种实现思路: - breakpoint-based:通过ptrace系统调用跟踪进程后,在目的函数地址处添加断点,恢复执行,等到命中断点后ptracer可读取函数参数信息、计算调用栈信息--stack,也可以在函数返回地址处添加断点,这样就可以函数进入、从函数返回时的时间戳来计算函数执行耗时--timestamp。 - ebpf-based:通过编写在要跟踪的函数地址处添加uprobe,在程序执行到此位置时,触发已经加载的ebpf程序,ebpf程序中收集事件信息,如函数参数信息,用户态程序接收事件并进一步完成统计,如输出调用栈--stack、输出函数执行耗时--timestamp。 这两种方案都有一个共同的问题需要解决,那就是: 1、先通过DWARF调试信息计算出定义了哪些函数,指定的正则表达式将与定义的函数列表进行匹配,匹配到的函数将被作为顶层函数追踪; 2、其次是分析函数调用栈,这个都需要通过执行到的pc来反推当前调用栈,这个和调试命令 `bt` 实现方案一致,要借助于 Call Frame Information; 3、再者要分析目标函数的函数体内的函数调用并通过 `--follow-calls=`控制调用深度,分析有哪些函数调用要借助对源码的AST分析; OK,trace调试命令,对于前后端分离式的调试器架构,前后端交互流程如下: 1. 用户在前端输入 `tinydbg trace [flags]` 命令。 2. 调试器后端初始化,如启动executable,或者通过ptrace操作attach目标进程,并等待进程停止; 3. 调试器前端初始化,初始化client,RPC获取函数定义列表,通过正则筛选匹配的函数,然后: - 如果是基于断点的方案,需要对每个函数的入口地址、返回地址添加断点; - 如果是基于ebpf的方案,需要对每个函数的入口地址、返回地址添加uprobes,并关联对应的ebpf事件信息收集、统计程序; 4. 5. 调试器前端初始化调试会话,如果是基于断点实现,需要执行ptrace、wait程序暂停、设置好断点后,continue让程序恢复执行,并通过RPC从调试器后端不断请求、接受最新的函数跟踪数据,并打印出来显示给用户; 6. 调试器前端ctrl+c结束时通过RPC通知调试器后端结束对目标进程的跟踪操作,如移除断点 or 移除uprobes、卸载ebpf程序; 由于trace的结果数据是源源不断的,理论上更合理的设计应该是上面这样的。但是考虑到tinydbg前后端交互缺少对流式调用的支持,而且执行trace操作时是不需要执行交互式的调试命令的,所以可以直接让调试器后端来输出结果。OK,这样的话,尽管我们还是前后端分离式架构,但具体来说是仅工作在通过net.Pipe通信这种模式下,不支持指定--headless模式下通过net.TCPConn或者net.UnixConn来进行网络通信 ### 代码实现 下面看下关键的函数执行流程,篇幅原因注意我们只保留了breakpoint-based实现方案,ebpf-based方案我们在 “3-高级功能扩展” 中进行介绍。 #### 前后端准备阶段 ```bash main.go:main.main \--> cmds.New(false).Execute() \--> traceCommand.Run() \--> traceCmd(...) // serverside \--> server := rpccommon.NewServer(...) \--> err := server.Run() \--> s.debugger, err = debugger.New(&config, s.config.ProcessArgs) \--> forloop \--> c, err := s.listener.Accept() \--> go s.serveConnection(c) \--> only `continue` will be received, let the ptracee continue \--> forloop with wait4(pid, ....) \--> print func info, including name, args, address, ... // clientside \--> client := rpc2.NewClientFromConn(clientConn) \--> funcs, err := client.ListFunctions(regexp, traceFollowCalls) \--> for range funcs \--> client.CreateBreakpoint(...), create bp at func entry \--> client.CreateBreakpoint(...), create bp at func return \--> cmds := debug.NewDebugCommands(client) \--> err = cmds.Call("continue", t) ``` 下面看下traceCmd源码: ```go func traceCmd(cmd *cobra.Command, args []string, conf *config.Config) int { status := func() int { ... var regexp string var processArgs []string dbgArgs, targetArgs := splitArgs(cmd, args) ... // Make a local in-memory connection that client and server use to communicate listener, clientConn := service.ListenerPipe() ... client := rpc2.NewClientFromConn(clientConn) ... funcs, err := client.ListFunctions(regexp, traceFollowCalls) if err != nil { fmt.Fprintln(os.Stderr, err) return 1 } success := false for i := range funcs { // Fall back to breakpoint based tracing if we get an error. var stackdepth int // Default size of stackdepth to trace function calls and descendants=20 stackdepth = traceStackDepth if traceFollowCalls > 0 && stackdepth == 0 { stackdepth = 20 } _, err = client.CreateBreakpoint(&api.Breakpoint{ FunctionName: funcs[i], Tracepoint: true, Line: -1, Stacktrace: stackdepth, LoadArgs: &debug.ShortLoadConfig, TraceFollowCalls: traceFollowCalls, RootFuncName: regexp, }) ... // create breakpoint at the return address addrs, err := client.FunctionReturnLocations(funcs[i]) if err != nil { fmt.Fprintf(os.Stderr, "unable to set tracepoint on function %s: %#v\n", funcs[i], err) continue } for i := range addrs { _, err = client.CreateBreakpoint(&api.Breakpoint{ Addr: addrs[i], TraceReturn: true, Stacktrace: stackdepth, Line: -1, LoadArgs: &debug.ShortLoadConfig, TraceFollowCalls: traceFollowCalls, RootFuncName: regexp, }) ... } } ... // set terminal to non-interactive cmds := debug.NewDebugCommands(client) cfg := &config.Config{ TraceShowTimestamp: traceShowTimestamp, } t := debug.New(client, cfg) t.SetTraceNonInteractive() t.RedirectTo(os.Stderr) defer t.Close() // resume ptracee err = cmds.Call("continue", t) if err != nil { fmt.Fprintln(os.Stderr, err) if !strings.Contains(err.Error(), "exited") { return 1 } } return 0 }() return status } ``` 关于client.ListFunctions(...)的工作过程,我们在how_listfunctions_work小节进行了详细介绍,感兴趣的读者可以先阅读相关小节了解下。这里我们先不过多介绍。 #### 函数跟踪结果输出 在breakpoint-based方案下,当ptracee命中断点时,ptracer会执行什么操作呢?执行什么操作,与对该断点的一些“修饰”有关。 在函数入口处添加断点的RPC操作如下: ```go _, err = client.CreateBreakpoint(&api.Breakpoint{ FunctionName: funcs[i], Tracepoint: true, Line: -1, Stacktrace: stackdepth, LoadArgs: &debug.ShortLoadConfig, TraceFollowCalls: traceFollowCalls, RootFuncName: regexp, }) ``` 在函数返回地址处添加断点的RPC操作如下: ```go _, err = client.CreateBreakpoint(&api.Breakpoint{ Addr: addrs[i], TraceReturn: true, Stacktrace: stackdepth, Line: -1, LoadArgs: &debug.ShortLoadConfig, TraceFollowCalls: traceFollowCalls, RootFuncName: regexp, }) ``` 注意这两个RPC请求参数的不同: - 在函数入口添加断点,指定的是函数名;而在返回地址处添加断点,却需要指定地址,而且这个地址还不止一个,同一个函数会在多处被调用,返回地址自然不止一个; - Tracepoint=true, TraceReturn=true,这是和常规断点的不同之处,在tracee命中断点暂停,ptracer根据当前pc-1处断点的这俩标识就可以确定是停在函数入口,还是函数返回地址处 - 获取参数:如果是函数入口,就可以根据go函数传参规则,以及DWARF、AST信息,来获取内存数据、参数在target中的类型和源码类型,并进行必要转换; - 计算耗时:如果是函数入口,就可以记录当前进入时间戳ts1,如果是出口就可以记录退出时间戳ts2,ts2-ts1进而就可以计算出耗时信息; 大致如此,在介绍到断点相关细节时,我们会进行进一步介绍,这里先不过多展开,读者先了解核心逻辑即可。 ### 本节小结 trace适合性能分析、异常检测和安全审计等场景,是非常有用的一种调试方法。但是需要注意一下breakpoint-based方案对性能的影响,如果考虑对性能影响最小,应该考虑ebpf-based方案。另外,有些读者也发现trace命令并不能将函数参数给完整打印出来(类似print vars)那样,这是很好理解的,因为这里考虑了对性能的影响。如果要将完整参数打印出来,包括跟踪参数内部的指针解引用,这将会包括非常多的类型解析、内存数据读取操作,程序暂停时间会很明显。 所以trace仅仅支持字符串类型的参数打印,关于这个不能打印参数的问题,也有网友在go-delve/delve讨论区进行了讨论,see: [Can dlv trace print the value of the arguments passed to a function?](https://github.com/go-delve/delve/issues/3586) 。 根据我的实践经验、心得,我认为即使trace当前不能打印参数,trace命令也仍然很有用。 > see: https://github.com/go-delve/delve/issues/3586#issuecomment-2911771133,翻译过来 > > 即使trace命令当前不能打印参数,trace命令也仍然很有用,比如,我们想做一些服务负载测试: > > 1) 通常情况下,微服务框架报告的RPC耗时已经足够了,但有时候还不够。 > - 耗时可能通过time.Duration指标或者Tracing span中的time.Duration来报告 > - 或者记录在日志文件中 > 但是为了避免压力,报告和日志记录逻辑可能都被禁用了。 > 或者opentelemetry后端不太好用,无法很好地可视化跟踪和span。 > 2) 可能我们知道特定RPC处理有瓶颈,比如某个函数调用(不是对callee的RPC调用), > 但我们不想手动使用golang runtime/trace包创建span,所以性能分析也帮不上忙。 > 3) 最糟糕的是,我们知道有瓶颈,但不知道是哪个函数调用导致的。 > 而且我们不想修改代码来添加日志。 > 可能CI/CD系统太耗时了,我们不想等它就绪...可能我们要重复多次。 > ... > 好吧,我需要一个跟踪工具来报告调用了哪些函数以及耗时多少。而且跟踪不应该给目标进程增加明显的时间开销。在这种情况下,我们不关心是否打印参数。另请参阅:[hitzhangjie/go-ftrace](https://github.com/hitzhangjie/go-ftrace)和[jschwinger233/gofuncgraph](https://github.com/jschwinger233/gofuncgraph),它们使用基于ebpf的解决方案,就像 `dlv trace`一样。 > > - 如果我们不想影响性能,应该使用基于ebpf的解决方案。我们仍然可以打印参数,但有限制,比如我们不解引用结构体字段以避免更多的PTRACE_PEEKDATA... > - 如果我们不关心性能影响,可以使用基于断点的解决方案,并添加一些代码来详细打印参数。 > 但正如aarzilli提到的,`trace `和 `on print `可以做到这一点。 OK,关于trace我们就先介绍到这里, ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/19-how_evalexpr_works.md ================================================ ## 支持表达式计算 ### 实现目标:理解并实现表达式计算 evalexpr 调试器调试过程中,我们经常会输入调试命令,有些调试命令允许输入一些表达式,比如 `print 1+2`, `print a+b`,`break 20`,`break *`,`whatis a.b.c` 等等。支持表达式计算能够让我们的调试过程更加便利,这个小节我们就来介绍下如何实现表达式计算。 ### 基础知识 其实大家对于表达式计算并不陌生,我举几个你可能“并不陌生”的例子,帮你梳理下曾经的知识点: - 读者学习《数据结构与算法》时,学习过中缀表达式转后缀表达式吧?还可以基于栈(运算符栈、操作数栈)的方式算出结果。 - 读者学习《编译原理》时,学习过词法分析、语法分析、语义分析吧,对抽象语法树AST有了解吧,其中包含了很多的表达式构造; 我们调试场景下支持的表达式,其实就是使用Go语言可以写出的表达式(表达式并不是语句),这也是为了以最小的记忆成本就可以写出想要的表达式。表达式操作数可以是常量、变量、指针等,运算符不仅包括 `+-*/()`,还包括索引操作 `[idx]`,slice操作 `[low:high]`,包括结构体成员访问操作 `.`等。 1. eval(expr),需要先解析理解这个表达式,这一步就需要进行此法分析、语法解析得到这个表达式的AST。 2. 然后我们需要walk这个AST,准备好运算符每个操作数的值: - 如果操作数是字面量true/false,这种操作数一般要处理为bool类型,然后再参与表达式计算; - 如果操作数是字面量nil,在Go表达式里面通常是表示一个nil指针或者无类型接口变量值; - 其他符号名,需要先在DWARF DIEs中搜索该符号对应的变量、常量定义,从而得知其具体类型信息 `DIE.DW_AT_type`,以及在内存中的位置 `DIE.DW_AT_location`。 3. 最后对表达式进行计算,处理不同运算符的计算逻辑,最终得到计算结果。如将AST转为后缀表达式,使用基于栈的方法进行运算,运算符栈空、操作数栈栈顶就是结果。 **注意:关于在DWARF DIEs中搜索符号定义,不得不强调下作用域的问题。** 变量、常量的定义是要注意区分作用域的,我们自然都明白。在DWARF DIEs中通过操作数名字搜索它的定义时,除了检查DIE的 `DW_AT_name` 是否与名字相同外,还要注意包含该DIE定义的当前PC是否位于 `DW_TAG_lexical_block[DW_AT_low_pc,DW_AT_high_pc]` 作用域范围内。如果不注意检查作用域相关的DIE,您可能最后使用了一个错误的同名变量,表达式计算结果就错了。 OK,我们来看下这部分的代码实现。 ### 代码实现 截止到现在,tinydbg支持Go语言中的如下表达式写法: - 除了 <-, ++ 和 -- 之外的所有基本类型上的二元和一元运算符 - 任意类型上的比较运算符 - 数值类型之间的类型转换 - 整型常量与任意指针类型之间的相互转换 - string、[]byte 和 []rune 之间的类型转换 - 结构体成员访问(例如:somevar.memberfield) - 数组、切片和字符串的切片和索引运算符 - Map访问 - 指针解引用 - 内置函数调用:cap、len、complex、imag 和 real - 接口变量的类型断言(例如:somevar.(concretetype)) #### Expr类型 Go标准库 `go/src/go/parser.ParseExpr(string)` 支持表达式解析并返回一个ast.Expr实例,ast.Expr是个接口类型,Go语言中的不同表达式都要实现这个接口。 see: go/src/go/ast/ast.go, ast.Expr ```go // All expression nodes implement the Expr interface. type Expr interface { Node // 这里额外定义了一个非导出方法,这样用户自定义的类型就无法赋值给ast.Expr类型的接量, // 因为只有go标准库内的表达式类型可以实现接口Expr。 exprNode() } // All AST node types implement the Node interface. type Node interface { Pos() token.Pos // position of first character belonging to the node End() token.Pos // position of first character immediately after the node } ``` 实现了ast.Expr接口的表达式,可以分为如下两类: - Value Expression, 包括 BadExpr, Ident, Ellipsis, BasicLit, FuncLit, CompositeLit, ParenExpr, SelectorExpr, IndexExpr, IndexListExpr, SliceExpr, TypeAssertExpr, CallExpr, StarExpr, UnaryExpr, BinaryExpr, KeyValueExpr; - Type Expressions, 包括 ArrayType, StructType, FuncType, InterfaceType, MapType, ChanType; 这就是理论上所有可以支持的Go表达式类型,调试器tinydbg支持了其中的绝大部分操作,本文前面提到过,只有极少数表达式操作不支持。 #### step1:AST解析 OK, 当输入一个表达式字符串后,调试器后端要先调用 `parser.ParseExpr(...)` 实现AST解析,返回一个ast.Expr。这个操作是通过go标准库函数 `go/parser.ParseExpr(x string)` 来完成的。 see: go/src/go/parser/interface.go ```go // ParseExpr is a convenience function for obtaining the AST of an expression x. // The position information recorded in the AST is undefined. The filename used // in error messages is the empty string. // // If syntax errors were found, the result is a partial AST (with [ast.Bad]* nodes // representing the fragments of erroneous source code). Multiple errors are // returned via a scanner.ErrorList which is sorted by source position. func ParseExpr(x string) (ast.Expr, error) { return ParseExprFrom(token.NewFileSet(), "", []byte(x), 0) } func ParseExprFrom(fset *token.FileSet, filename string, src any, mode Mode) (expr ast.Expr, err error) { // get source from filename or use src directly text, _ := readSource(filename, src) // parse expr var p parser file := fset.AddFile(filename, -1, len(text)) p.init(file, text, mode) expr = p.parseRhs() ... return } ``` 因为是Go标准库中的实现,我们就不浪费太多篇幅展示相关的源代码了,感兴趣的读者可以自行查看源码。毕竟大家有编译原理基础,结合 [Go语言文法](https://go.dev/ref/spec) 手写一个词法分析器、语法分析器也不困难。 > 可能有些读者毕业之后再也没有接触过编译相关的实践,OK,那这里提供个可视化工具,供大家了解下,[AST Explorer](https://astexplorer.net/),你可以在页面上编程语言的源代码,右侧就可以及时输出解析后的AST。如果你对上述提及的ast.Expr的具体实现类型没有任何一点感觉,不代表你看不懂,只是你很少涉猎这方面的内容,所以看了后很陌生。 > > 举几个简单例子吧,你可以像这样写一些并不用十分完整的Go代码,看下解析出来的AST结构,比如这里的表达式IndexExpr、SliceExpr、BinaryExpr,这样你就知道了我们输入的表达式,最终解析完后大约长什么样子。 > > ```go > // ast.File.Name.(*Ident).Name="main" > package main > > // GenDecl.Tok="import" > // GenDecl.Specs[0].(*ImportSpec).Path.(*BasicLit).Value="\"fmt\"" > // GenDecl.Specs[0].(*ImportSpec).Path.(*BasicLit).Kind="STRING" > import "fmt" > > // main.main对应FuncDecl,FuncDecl.Body.Statements表示函数体中的每一行语句 > func main() { > var nums []int // DeclStmt.Decl.(*GenDecl).Tok="nums", > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Type=ArrayType, > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Elt.(*Ident).Name="int" > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Values=[] > > nums[1] // ExprStmt.X.(*IndexExpr).X.(*Ident).name="nums" > // ExprStmt.X.(*IndexExpr).Index.(*BasicLit).Value=1 > // ExprStmt.X.(*IndexExpr).Index.(*BasicLit).Kind=INT > > nums[1:2] // ExprStmt.X.(*SliceExpr).X.(*Ident).Name="nums" > // ExprStmt.X.(*SliceExpr).Low.(*BasicLit).Value="1" > // ExprStmt.X.(*SliceExpr).Low.(*BasicLit).Kind="INT" > // ExprStmt.X.(*SliceExpr).High.(*BasicLit).Value="2" > // ExprStmt.X.(*SliceExpr).High.(*BasicLit).Kind="INT" > > var a int // DeclStmt.Decl.(*GenDecl).Tok="var" > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Type.(*Ident).Name="int" > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Names[0].(*Ident).Name="a" > var b int > var c int > > var d = a + b*c // DeclStmt.Decl.(*GenDecl).Tok="var" > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Names[0] = &Ident{Name:"d"} > // DeclStmt.Decl.(*GenDecl).Specs[0].(*ValueSpec).Values[0] = > // &BinaryExpr{Op:"+", X:&Ident{Name:"a"}, Y:&BinaryExpr{Op:"*", X:&Ident{Name:"b"}, Y:&Ident{Name:"c"}}} > } > ``` #### step2: EvalScope OK,现在从输入的表达啥字符串得到了ast.Expr之后,我们如何进行计算呢?如果我们把符号替换成对应的真实值(比如变量名a,b,c对应的数值),然后再结合运算表达式进行计算,不就得到最终结果了吗?确实如此。 但是要通过符号名,知道它到底是个int还是个string,还是个slice或者其他类型,我们需要知道它的确切定义信息。同一个符号名,在源码不同作用域下可能会被多次使用,这很常见。因此,我们还必须提供执行此表达式时的硬件上下文信息(PC),进而确定当前执行指令所处的源码作用域,然后在该作用域中搜索对应的符号定义,如果找不到再继续搜索上一层作用域。 这里举个简单的例子: ```go $ cat main.go 1 package main 2 3 func main() { 4 var a string = "helloworld" 5 var b int = 100 6 _ = a 7 _ = b 8 { 9 var a int = 200 10 _ = a 11 } 12 } ``` 假设当前执行到main.go:6,此事变量a是一个值为"helloworld"的字符串,但是当执行到main.go:10时,变量a变成了值为"200"的int类型变量。 how DWARF desc lexcial block 读者一看就明白了,表达式中操作数的作用域信息很关键,对,那作用域信息从何得到呢?就是要根据当前调试器执行到的指令PC,结合DWARF调试信息中DW_TAG_subprogram、DW_TAG_lexical_block的作用域范围 `[DW_AT_low_pc,DW_AT_high_pc]`,我们就可以确定当前搜索符号的定义时,应该优先从哪个作用域开始搜索。比如上面的例子,如果当前PC位于main.go:10这行,如果查找a的定义,那我们就应该优先在这个block内搜索到`var a int = 200`,而如果是搜索b,也要先搜索当前block,但是这个block没有定义,此时应该查找更上一层block,即搜索main.main这个block,进而搜索到 `var b int = 100`。 至此,大家应该了解了,当我们知道了当前硬件上下文信息(PC),结合DWARF调试信息,我们不光可以推断出当前程序执行到的栈帧、函数,这部分主要是靠搜索FDEs,我们还可以通过搜索DIEs找到当前的作用域,任意给出一个符号名,还可以准确找到符号定义相关信息,DWARF不仅描述了作用域信息,也描述了某个作用域中定义了那些变量,包括变量值在内存中的位置,以及变量类型信息。 OK,proc.EvalScope中就记录了一些当前的硬件上下文信息,比如proc.EvalScope.Regs。当借助DWARF确定了变量在内存中的位置之后,我们还可以通过proc.EvalScope.target去读取内存数据(实际上封装了`ptrace(PTRACE_PEEKDATA,...)`,然后再根据DWARF中记录的类型信息,借助Go reflection精准构造出对应的变量实例。最后就可以在这个变量实例上执行相应的运算符对应的计算逻辑。 see: tinydbg/pkg/proc/eval.go: proc.EvalScope ```go // EvalScope is the scope for variable evaluation. Contains the thread, // current location (PC), and canonical frame address. type EvalScope struct { Location Regs op.DwarfRegisters Mem MemoryReadWriter // Target's memory g *G threadID int BinInfo *BinaryInfo target *Target loadCfg *LoadConfig frameOffset int64 // When the following pointer is not nil this EvalScope was created // by EvalExpressionWithCalls and function call injection are allowed. // See the top comment in fncall.go for a description of how the call // injection protocol is handled. callCtx *callContext dictAddr uint64 // dictionary address for instantiated generic functions enclosingRangeScopes []*EvalScope rangeFrames []Stackframe } ``` ps:proc.EvalScope还包含了一些其他的上下文信息,这里先不介绍。值得一提的是,proc.EvalScope不同于api.EvalScope,后者是为了识别调试会话命令是在哪个goroutine、哪个栈帧、哪个defer函数中执行来定义的。而proc.EvalScope是为了支持表达式计算而定义的。 #### step3: 表达式计算 OK,现在我们了解了如何将输入的表达式解析为AST(ast.Expr),并且了解了应该如何确定变量在源码中的定义(作用域、类型、内存地址),简单提了下内存数据读取、借助DWARF类型描述以及Go反射机制可以对表达式中的符号用对应的变量实例精准替换。有了这些之后,我们就可以了解不同的ast.Expr支持哪些运算符,然后去实现这些运算符的计算逻辑。 每一种不同类型的表达式,它对应的类型、运算符也不一样,比如SliceExpr描述的是获取一个slice、string、array的subslice,而IndexExpr描述的是获取一个slice、string、array特定下标位置的元素……OK,笼统来说,剩余的就是针对每种表达式的计算逻辑。 ```go var expr ast.Expr expr, _ := parser.ParseExpr(s string) switch vv := expr.(type) { case ast.Ident: evalIdent(...) case ast.Ellipsis: evalEllipssis(...) case ast.SliceExpr evalSliceExpr(...) case ast.StarExpr evalStarExpr(...) case ast.UnaryExpr evalUnaryExpr(...) case ast.BinaryExpr evalBinaryExpr(...) ... } ``` OK,你可以按照我这样的描述来理解表达式就是这样计算的,完全没问题,2015年aarzilli提交了dlv[第一版表达式计算实现](https://github.com/go-delve/delve/pull/285/commits/43b64ec39e8188cd5b215d15ec0175868c548a8b),这个版本大致实现逻辑就是前面介绍的这样。2023年aarzilli提交了dlv[第二版表达式计算实现:基于栈机器的表达式计算](https://github.com/go-delve/delve/commit/2c700230de0db2f84c537f00b388b183d35645ac)。按aarzilli的说法是,这么做有诸多好处。 >之前 Delve 的表达式求值是递归实现的,涉及 goroutine 和 channel 通信,结构复杂且难以维护。新方案将表达式“编译”为一组指令,然后用栈机器顺序执行,这样结构更清晰,易于维护和扩展。这样可以更好地支持运行时函数调用注入、变量状态同步以及未来的新特性实现,比如利用 Go 1.20 的 runtime.Pin 优化。 整体提高了可扩展性和兼容性,并简化了运行时状态同步的逻辑。 OK,我们来了解下当前这个版本的实现,将表达式解析为ast.Expr,然后将其编译为一组操作指令,然后基于栈机器执行这组指令,最后计算完成得到结果。 #### Put It Together ##### 整体流程 `proc.(*EvalScope).EvalExpression(expr string, cfg LoadConfig) (*Variable, error)` 执行了上述提及的所有操作: 1. 对输入表达式 `expr string` 的AST解析,得到表达式ast.Expr实例; 2. 然后结合上下文信息确定当前表达式中各个操作数的作用域,进而确定具体的操作数的值, 3. 最终根据不同表达式的计算规则,完成最终结果的计算,计算结果是一个Variable。 ps:这里的计算,tinydbg采用了一种巧妙的办法,它将不同ast.Expr对应的处理操作编译成了一系列的操作指令。然后执行这些操作指令,最终就得到了计算结果。 ```go // EvalExpression returns the value of the given expression. func (scope *EvalScope) EvalExpression(expr string, cfg LoadConfig) (*Variable, error) { // 解析ast.Expr,编译为一系列操作指令 ops, _ := evalop.Compile(scopeToEvalLookup{scope}, expr, scope.evalopFlags()) // 执行指令,最终得到结果 stack := &evalStack{} scope.loadCfg = &cfg stack.eval(scope, ops) // 执行结束,获取执行结果 ev, _ := stack.result(&cfg) // 对于表达式中的变量,查询DWARF得到了其类型信息、内存地址信息,会为其构造类型名为Variable的变量加以表示。 // 当将这些变量入栈时(stack.stack参数栈),才会加载内存中数据,而且还会cache,因为同一个变量可能出现多次。 // // 但是对于计算结果,`Variable.Addr==0 && Variable.Base==0`,不用从被调试进程内存中读取数据。 ev.loadValue(cfg) if ev.Name == "" { ev.Name = expr } return ev, nil } ``` see: tinydbg/pkg/proc/evalcompile.go, evalop.Compile ```go // Compile compiles the expression expr into a list of instructions. // If canSet is true expressions like "x = y" are also accepted. func Compile(lookup evalLookup, expr string, flags Flags) ([]Op, error) { // 先解析为ast.Expr t, err := parser.ParseExpr(expr) if err != nil { ... } // 将不同的ast.Expr编译为不同的操作指令序列, // 每条指令有入栈、出栈、计算逻辑相关的操作指令: // - 比如pushIndent只涉及到入栈,一般是入栈操作数变量,比如a+b涉及到两条指令pushIdent(a), pushIdent(b) // - 比如pushBinary涉及到出栈、计算、入栈,比如从操作数栈出栈两个操作数Variable(a), Variable(b), // 然后执行计算逻辑 Variable(c) = Variable(a)+Variable(b),然后将计算结果入栈 // // 不同的指令有不同的操作序列,OK! return CompileAST(lookup, t, flags) } // CompileAST compiles the expression t into a list of instructions. func CompileAST(lookup evalLookup, t ast.Expr, flags Flags) ([]Op, error) { // 编译为一系列操作指令 ctx := &compileCtx{evalLookup: lookup, allowCalls: true, flags: flags} _ := ctx.compileAST(t, true) ... // 栈深度校验 _ = ctx.depthCheck(1) return ctx.ops, nil } ``` ##### 如果你想细致掌握 如果你想细致掌握每一个不同的表达式类型的详细处理过程,你可以阅读源码,也可以通过dlv调试器调试tinydbg调试器,然后在tinydbg调试会话中输入简单的表达式`whatis `来触发表达式计算过程,这样你可以单步执行的方式跟踪每一个细节,不至于淹没在巨量的分支代码逻辑中。这部分内容如果每个表达式类型我们都详细介绍的话,篇幅会非常非常大,我们不大可能在有限的篇幅内全盘介绍。所以如果你是和作者一样喜欢刨根问底、希望对每个细节了如指掌,那你可以这么做。 如果真的准备这么干,可以按照下列步骤跟踪调试器的内部执行过程: 首先准备一个测试程序 main.go: ```go 1 package main 2 3 func main() { 4 var a string = "helloworld" 5 var b int = 100 6 _ = a 7 _ = b 8 { 9 var a int = 200 10 _ = a 11 println(a+b) // <= 我们将执行 `break main.go:11` 12 } 13 } ``` 然后安装调试器tinydbg,注意要禁用优化并生成DWARF调试信息: ```bash $ go install -v -gcflags 'all=-N -l' github.com/hitzhangjie/tinydbg ``` 然后启动调试器tinydbg进行调试: ```bash $ tinydbg debug main.go (tinydbg) break main.go:11 ... (tinydbg) continue (tinydbg) whatis a+b // <= whatis会触发调试器表达式Eval操作 // 注意,此时先不要好敲回车键, // 我们要用调试器调试调试器,得给调试器tinydbg加断点 ``` OK,现在我们用另一个调试器dlv来调试上述tinydbg进程: ```bash dlv attach `pidof tinydbg` (dlv) break EvalExpression (dlv) continue (dlv) 当whatis执行后,dlv会停在tinydbg的proc.EvalExpression函数位置,等待调试 ``` 断点创建成功后,回到tinydbg调试会话中,敲击“回车键”,触发`whatis`命令执行,此时调试器tinydbg后端逻辑会执行`proc.EvalExpression(s)`操作,dlv调试会话中会停在该函数断点处,此时你就可以通过单步执行,来了解详细的EvalExpression过程了。 ##### 以 `a+b` 进行说明 尽管我们不能兼顾每个细节,但是我们还是希望能对关键处理路径进行介绍,这样读者朋友们才不会觉得,“嗯,又是一本部头很大但是啥也没讲清楚的书……失望”。 所以,我们按照上面的main.go示例,解释下执行 `whatis a+b` 时,EvalExpression逻辑是如何执行的,当读者跟随这里的描述过完这一遍的流程之后,你将彻底明白这个过程是如何工作的,以及有能力去深入探索其他表达式计算逻辑是如何执行的。 还是老习惯,先列下大致的核心代码路径。 **client**: client端代码比较简单,大致如下所示。 ```bash whatisCmd.cmdFn \--> whatisCommand(t *Session, ctx callContext, args string) error \--> val, err := t.client.EvalVariable(ctx.Scope, args, ShortLoadConfig) \--> (c *RPCClient) EvalVariable(scope api.EvalScope, expr string, cfg api.LoadConfig) (*api.Variable, error) \--> err := c.call("Eval", EvalIn{scope, expr, &cfg}, &out) \--> print the variable info ``` **server**: server端涉及到的代码量非常大,我分3个关键步骤进行介绍。 加载配置,并发起表达式计算: ```bash tinydbg/service/rpc2(*RPCServer).Eval(arg EvalIn, out *EvalOut) error \--> cfg = &api.LoadConfig{FollowPointers: true, ...) | \--> pcfg := *api.LoadConfigToProc(cfg) \--> v, _ := s.debugger.EvalVariableInScope(arg.Scope.GoroutineID, arg.Scope.Frame, arg.Scope.DeferredCall, arg.Expr, pcfg) | \--> s, err := proc.ConvertEvalScope(d.target.Selected, goid, frame, deferredCall) | \--> return s.EvalExpression(expr, cfg) ``` 编译阶段,对表达式字符串进行AST解析,并对ast.Expr编译一系列操作指令ops,这里的指令并不是机器指令 :) >执行完编译之后,ctx.ops将包含3个操作,入栈ident{a},入栈ident{b},执行+计算,活脱脱一个后缀表达式但是这里的ctx.ops并不是最终要执行的执行。 ```bash return s.EvalExpression(expr, cfg) // step1: 进行AST解析,并将其表达式求值操作编译为一系列操作指令 \--> ops, err := evalop.Compile(scopeToEvalLookup{scope}, expr, scope.evalopFlags()) | // ast分析得到ast.Expr | \--> t, err := parser.ParseExpr(expr) | // 对ast.Expr进行编译 | \--> return CompileAST(lookup, t, flags) | \--> err := ctx.compileAST(t, true) | \--> `a+b` operator `+`: case *ast.BinaryExpr: err := ctx.compileBinary(node.X, node.Y, sop, &Binary{node}) | \--> operand `a`: err := ctx.compileAST(a, false) | \--> ctx.pushOp(&PushIdent{node.Name}) | \--> ctx.ops = append(ctx.ops, op) | \--> operand `b`: err := ctx.compileAST(b, false) | \--> ctx.pushOp(&PushIdent{node.Name}) | \--> ctx.ops = append(ctx.ops, op) | \--> ctx.ops = append(ctx.ops, op) | \--> operator `+`: ctx.pushOp(op) // `op` is `&Binary{node}` | // `ctx.pushOp(op OP)`放到ctx.ops里的每一个操作,都是OP接口的实现, | // OP接口要求各个操作汇报各自的popstack、pushstack的次数, | // 如: | // - pushIdent分别popstack 0次,pushstack 1次,因为仅需要入栈1个参数; | // - Binary则是popstack 2次,pushstack 1次,因为二元运算符要通过2次popstack得到2个参数,结果再入栈1次; | // 这里的栈深度检查,即校验这些操作执行完后,目标栈深度是否符合预期,如果不符合预期那设计的操作指令有问题。 | \--> err = ctx.depthCheck(1) | \--> return ctx.ops // step2: evalStack执行指令阶段,这个过程就是对表达式求值的过程 // step3: 从evalStack操作数栈栈顶获取计算结果 ... ``` 指令执行,初始化一个evalStack(栈机器),它会执行我们前面编译expr生成的操作序列: ```bash // step2: evalStack执行指令阶段,这个过程就是对表达式求值的过程 stack := &evalStack{} stack.eval(scope, ops) \--> stack.ops = ops \--> stack.scope = scope \--> stack.spoff = ... / stack.bpoff = ... / stack.fboff = ... / stack.curthread = ... \--> stack.run() ``` OK, 我们看下这个栈机器是如何执行我们编译后的操作序列的,在我们的例子中,stack.ops 包含3个操作,前两个操作是为最后一个操作准备操作数的。 1. 第1个操作、第2个操作分别是 pushIdent{a} 和 pushIdent{b}, 它们会在下面代码中的分支 `case *evalop.PushIdent` 中处理。 从结果来看,它们的不同之处在于: - 变量 `a` 找到2个定义, 1) block 8~12, line 9; 2) main.main 3~13, line 4,最终确定其定义应为block 8~12, line 9处的定义; - 变量 `b` 找到1个定义, main.main 3~13, line 5; 2. 第3个操作是 pushBinary{+}, 下面代码分支中 `case *evalop.Binary` 中处理。 ```bash stack.run(): \--> foreach op in stack.ops: | for stack.opidx < len(stack.ops) && stack.err == nil, | \--> stack.executeOp() | \--> switch op := ops[stack.opidx].(type) case *evalop.PushIdent: ... // pushIdent{a}, pushIdent{b} case *evalop.Binary: ... // pushBinary{+} ``` OK,我们先来看看pushIdent{a}, pushIdent{b}这个处理分支是如何处理的:优先搜索函数作用域以及内部作用域有没有同名变量定义,找不到再从包级别作用域查找。 ```bash op = stack.ops[stack.opidx] switch op := ops[stack.opidx].(type) ... case *evalop.PushIdent: stack.pushIdent(scope, op.Name) | \--> step1: search `name` from function scopes and inner blocks | | found = stack.pushLocal(scope, name, 0) | | \--> vars, _ = scope.Locals(0, name) | | | \--> vars0, _ := scope.simpleLocals(flags|rangeBodyFlags, wantedName) | | | | \--> dwarfTree, _ := scope.image().getDwarfTree(scope.Fn.offset) | | | | \--> varEntries := reader.Variables(dwarfTree, scope.PC, scope.Line, variablesFlags) | | | | | \--> variablesInternal(nil, root, 0, pc, line, flags, true) | | | | | | // search main.main scope and inner blocks | | | | | | case dwarf.TagLexDwarfBlock, dwarf.TagSubprogram: | | | | | | \--> if (... || root.ContainsPC(pc) then | | | | | | check each children of root.Children: | | | | | | v = variablesInternal(v, child, depth+1, pc, line, flags, false) | | | | | \--> return varEntries | | | | \--> vars := make([]*Variable, 0, len(varEntries))vars [] | | | | \--> foreach var in varEntries | | | | | var, := extractVarInfoFromEntry(scope.target, scope.BinInfo, scope.image(), ......) | | | | | | \--> 由DIE.DW_ATTR_type读取类型信息, 由DIE.DW_ATTR_location计算地址(DWARF定义的栈字节码指令) | | | | | | | n, t, _ := readVarEntry(entry, image) | | | | | | | addr, _, _, := bi.Location(entry, dwarf.AttrLocation, regs.PC(), regs, mem) | | | | | | \--> 创建新变量,初始化类型信息、数据地址 | | | | | | | v := newVariable(n, uint64(addr), t, bi, mem) | | | | | | | return v | | | | | vars = append(vars, var) | | | | \--> 按定义位置(源码上)进行排序、嵌套深度进行排序 | | | | | sort.Stable(&variablesByDepthAndDeclLine{vars, depths}) | | | | \--> 将被shadow的外部作用域的变量标识下 | | | | | mark vars `flags|=VariableShadowed` if shadowed | | | | \--> return vars | | | \--> only keep the lastseen one in vars0 | | | | that's the one defined in expected scope, | | \--> foreach var in vars | | | found := varflags&VariableShadowed == 0 | | | if found then | | | stack.push(vars[i]) | | | \--> stack.stack = append(stack.stack, v) | | | break | | \--> return found | | | \--> step2: if `found`, then return vars | | | \--> step3: if `!found`, then find in globals | v, err := scope.findGlobal(scope.Fn.PackageName(), name) | \--> search pacakge.variable, if found then return | \--> search package.functions, if found then returns | \--> search package.constants, if found then returns ``` 第1、2个操作执行完后就完成了操作数栈stack.stack的入栈操作,入栈时的变量Variable.Type+Variable.Location都已经根据DWARF信息初始化过了,只是Variable.Value还没有加载。在evalStack这个栈机器取出二元运算符“Binary{+}"之后,会尝试从操作数栈获取操作数,这个阶段,会从被调试进程的内存中读取数据到Variable.Value,然后再进行基于运算符逻辑的加法计算。 OK,我们下面来看下Binary{+}这个操作的处理逻辑: ```bash op = stack.ops[stack.opidx] switch op := ops[stack.opidx].(type) ... case *evalop.Binary: scope.evalBinary(op, stack) \--> step1: 从操作数栈stack.stack获取运算符的左右操作数 | yv := stack.pop(); | | \--> v := s.stack[len(s.stack)-1] | | \--> s.stack = s.stack[:len(s.stack)-1] | | \--> return v | xv := stack.pop(): 略 \--> step2: 根据Variable中记录的地址信,加载其在被调试进程内存数据 | xv.loadValue(...); | | \--> v.loadValueInternal(0, cfg) | | \--> if v.Kind == reflect.Int, ..., reflect.Int64 then | | | var val int64 | | | val, v.Unreadable = readIntRaw(v.mem, v.Addr, v.RealType.(*godwarf.IntType).ByteSize) | | | \--> val := make([]byte, int(size)) | | | \--> _, err := mem.ReadMemory(val, addr) | | | | \--> n, _ = processVmRead(t.ID, uintptr(addr), data) | | | | uses syscall SYS_PROCESS_VM_READV, maybe failed | | | | \-- if n == 0 then use syscall ptrace(PTRACE_PEEKDATA, ...) | | | | t.dbp.execPtraceFunc(func() { n, err = sys.PtracePeekData(t.ID, uintptr(addr), data) }) | | | \--> n = int64(binary.LittleEndian.Uint64(val)) | | | \--> return n | | | v.Value = constant.MakeInt64(val) | yv.loadValue(...): 略 \--> step3: 校验运算符左右操作数类型是否一致 typ, err := negotiateType(node.Op, xv, yv) \--> step4: 构造计算结果 | rc, err := constantBinaryOp(op, xv.Value, yv.Value) | | \--> if op isn't token.SHL, token.SHR then | | | r = constant.BinaryOp(x, op, y) | | | \--> a := int64(x), b := int64(y) | | | \--> if op == token.Add then c = a+b | | | \--> return int64Val(c) | | \--> return r \--> step5:结果变量类型应该和操作数相同,构造一个新结果变量 | r := xv.newVariable("", 0, typ, scope.Mem) | r.Value = rc \--> step6: 确保计算结果符合目标平台的限制, see: convertInt中符号位扩展、截断处理逻辑 | switch r.Kind | case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: | \--> n, _ := constant.Int64Val(r.Value) | \--> r.Value = constant.MakeInt64(int64(convertInt(uint64(n), true, typ.Size()))) \--> step7: 最后,将计算结果放入操作数栈 stack.push(r) \--> s.stack = append(s.stack, v) ``` 已经计算完成,且已经将计算结果变量入栈,此结果变量的Variable.Value已经有只值了,且结果变量 `Variable.Addr==0 && Variable.Base==0` 。OK,接下来我们来读取计算结果。 ```bash step1: 栈机器的操作数栈栈顶就是最终计算结果,取出这个变量,这个变量是个计算结果,ev.loaded=true,不用读进程内存进行加载 ev, err := stack.result(&cfg) \--> r = stack.peek() \--> r.loadValue(*cfg) | \--> v.loadValueInternal(0, cfg) | // r这个结果变量,是调试器进程构造出来的,r的结果不存储在被调试进程中, | // 所以这里 `v.Addr == 0 && v.Base == 0` 成立,无需从被调试进程内存中加载,直接返回 | \--> if v.Unreadable != nil || v.loaded || (v.Addr == 0 && v.Base == 0) then return \--> return r step2: 这次loadValue对这里的场景来说,有点多余,前面stack.peek()过程已经loadValue过了。 ev.loadValue(cfg) ``` 最后,由于是前后端分离式架构,只有调试器后端所在进程上的数据类型定义,需要告知调试器前端,前端才能正常展示。 ```bash func (s *RPCServer) Eval(arg EvalIn, out *EvalOut) error \--> v, err := s.debugger.EvalVariableInScope(arg.Scope.GoroutineID, arg.Scope.Frame, arg.Scope.DeferredCall, arg.Expr, pcfg) \--> 将Eval的结果proc.Variable转换成客户端可读的信息api.Variable | out.Variable = api.ConvertVar(v) | \--> r := Variable{Addr, Name, Kind, Len, Cap, ...} | \--> r.Type = PrettyTypeName(v.DwarfType) | \--> godwarf.Type.String(), 这里就是int | \--> r.Value = VariableValueAsString(v), 这里就是a+b的结果字符串"300" | \--> return &r ``` 客户端收到响应后,得知这个结果类型是int,并且当前结果值是"300",就可以将结果转换后,输出到调试会话窗口了。 ### 执行测试 略 ### 本节小结 至此整个 `whatis` 等命令中涉及到的表达式计算过程就执行结束了。我们只介绍了 `a+b` 这么简单的表达式,就花去了大量的篇幅,但是好的一点是,我们把处理过程中所有重要的细节都给大家介绍到了。包括AST解析,编译ast.Expr为一系列操作序列,以及evalStack这个栈机器如何执行这个序列,并得到计算结果。最后我们还介绍了如何将目标平台数据结构,转换为调试器前端可以理解的结构样式。 如果您对其他表达式类型的设计实现感兴趣,您可以参考本文介绍的调试过程去跟踪调试,或者根据本文介绍的源代码关键路径,去阅读相关源码。OK,本文就到这里了。 ### 参考文献 1. 后缀表达式, https://en.wikipedia.org/wiki/Reverse_Polish_notation 2. Go语言文法, https://go.dev/ref/spec 3. AST Explorer, https://astexplorer.net/ 4. dwarfviewer, https://github.com/hitzhangjie/dwarfviewer ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/20-how_locspec_works.md ================================================ ## locspec解析与地址转换 符号级调试器和指令级调试器相比,最明显的不同之一就是我们可以使用字符串来表示位置信息,如添加断点时、反汇编时可以使用"文件名:行号"、"函数名"来表示目标地址。为了调试时更加便利,我们需要设计一些大家常用、容易记住、容易输入的位置描述方式,这里我们就叫做locationspec,简称locspec了。 ### 实现目标:支持locspec解析及地址转换 本节我们就结合多种调试器中的断点操作、反汇编操作等等,常见的locspec操作如下: - `` 完整的源文件路径,或者是源文件路径的某一段后缀; - `` 任何有效的Go函数名、方法名,使用后缀也可以,如: - `..` - `.(*).` - `.` - `.` - `(*).` - `` ps: 使用其后缀也可以,但是如果有冲突要提示存在冲突函数名。 - `//` 名字与正则表达式匹配的所有函数、方法; - `+` 当前行后面+offset行; - `-` 当前行前面-offset行; - `` 当前源文件中的行号,也可与 ``结合使用; - `*
` 内存地址address处数据作为地址; 最后总结一下,我们要支持locspec它的文法应该满足:`locStr ::= : | [:] | // | (+|-) | | *
` . 我们本节不仅要根据locspec实现对输入地址字符串的解析,还要能够将其转换为内存中的地址,OK,接下来我们一起来实现它。 ### 代码实现 #### locspec 结合前面确定的locspec `locStr ::= : | [:] | // | (+|-) | | *
`,我们明确了要支持的几种位置描述类型。 结合调试器tinydbg加断点操作,来说明下使用时是什么效果: - 文件名:行号,比如 `break main.go:20`,在指定源文件main.go的20行 - 函数名,比如 `break main.main`,在指定package的指定函数 main.main - regexp,比如 `break main.(*Student).*`,在所有匹配正则的函数名、方法名处添加断点 - +offset,比如 `break +2`,在当前断点行-2行位置 - -offset,比如 `break -2`,在当前断点所在行-2行位置 - line,比如 `break 20`,在当前源文件20行 - `*address`,比如 `*0x12345678`,指定内存地址中的地址处 see: tinydbg/pkg/locspec/locations.go,这定义了每个locspec类型必须满足的接口定义LocationSpec,以及不同locspec类型的定义以及解析函数。 #### 位置类型 结合前面locspec文法的定义,这了看下每种位置类型的定义,将输入位置字符串解析为不同LocationSpec实现的逻辑我们就省略了。我们将重点放在不同LocationSpec如何将human-redable位置描述转换为内存地址的过程。 ps:这个转换过程当然是在调试器后端实现的,因为转换的过程涉及到“符号层(Symbolic Layer)”、“目标层 (Target Layer)”的操作。 see: tinydbg/pkg/locspec/locations.go ```go // 函数位置 // 比如 main.main, PackageName=main, BaseName=main type FuncLocationSpec struct { PackageName string AbsolutePackage bool ReceiverName string PackageOrReceiverName string BaseName string } // 文件:行号 or funcName:行号 // 比如 main.go:20,Base=main.go,LineOffset=20 type NormalLocationSpec struct { Base string FuncBase *FuncLocationSpec LineOffset int } // 指定行号位置(相对于当前源文件) // 比如20,Line=20 type LineLocationSpec struct { Line int } // 源码行基础上加减行数(相对于当前源码行) // 比如+20,Offset=+20 type OffsetLocationSpec struct { Offset int } // 解引用内存位置得到地址 // 比如0x123456,AddrExpr=0x123456 type AddrLocationSpec struct { AddrExpr string } // 名字与正则表达式匹配的函数、方法 // 比如/[a-z].*/,FuncRegex=/[a-z].*/ type RegexLocationSpec struct { FuncRegex string } ``` #### 地址解析 开发人员调试时,会在调试器前端输入位置字符串,比如添加断点时输入 `break main.main` , `break main.go:20` , `break main.main:20` 等不同位置写法。需要调试器后端进行代为处理的,调试器前端会在JSON-RPC请求参数中设置必要的位置信息,就是输入的位置描述字符串。到了调试器后端这里,接收到调试请求参数后,会发现原来是个加断点请求,并且参数里的位置字符串指明了位置,就是下面的locStr了。 接下来就是,调试器先解析这个locStr,我们一起粗略看下解析过程吧,对照着前面的locspec,一看就能理解Parse的含义,Parse的结果是一个LocationSpec实现,可能是FuncLocationSpec, LineLocationSpec or others。 see: tinydbg/pkg/locspec/locations.go ```go // Parse will turn locStr into a parsed LocationSpec. func Parse(locStr string) (LocationSpec, error) { rest := locStr ... switch rest[0] { case '+', '-': // 解析 `+/-` offset, _ := strconv.Atoi(rest) return &OffsetLocationSpec{offset}, nil case '/': if rest[len(rest)-1] == '/' { // 解析 `/regexp/` 正则表达式位置描述 rx, rest := readRegex(rest[1:]) if len(rest) == 0 { return nil, malformed("non-terminated regular expression") } if len(rest) > 1 { return nil, malformed("no line offset can be specified for regular expression locations") } return &RegexLocationSpec{rx}, nil } else { // 解析 `文件行号、函数行号` 位置描述 return parseLocationSpecDefault(locStr, rest) } case '*': // 解析 *
位置描述 return &AddrLocationSpec{AddrExpr: rest[1:]}, nil default: // 解析 `文件行号、函数行号` 位置描述 return parseLocationSpecDefault(locStr, rest) } } ``` #### 地址转换 locspec的目的是为了将位置描述字符串转换为内存中的地址,所以针对locspec定义了这样一个接口LocationSpec。 locspec主要是在client端调试会话中进行输入,然后RPC传给服务器侧,服务器侧将其解析为具体的LocationSpec实现,之后的最常用操作就是使用 `XXXLocationSpec.Find(t, args, scope, locStr, ...)` 来将locStr转换为内存地址。 ```go type LocationSpec interface { // Find returns all locations that match the location spec. Find(t *proc.Target, processArgs []string, scope *proc.EvalScope, locStr string, includeNonExecutableLines bool, substitutePathRules [][2]string) ([]api.Location, string, error) } ``` 调试器后端和目标进程进行交互,可以读取它的二进制、DWARF、进程等信息,可以将上述输入的"位置描述"字符串精确转换为内存地址。 每一种LocationSpec实现结合实际情况实现这样的查询操作Find,如何实现Find操作的呢?每个LocationSpec实现有不同的实现逻辑,比如: - `*
` 就需要涉及到 `ptrace(PTRACE_PEEKDATA,...)` 读取内存中数据, - 再比如NormalLocationSpec通常是 `文件名:行号`,这种就需要利用DWARF调试信息中的行号表信息,转换出这行对应的指令地址, - 再比如如果是 `FuncLocationSpec` 就需要根据DWARF调试信息中的FDE信息,再找到该函数所包含指令的起始地址 - ... 所以你看,不同的locspec LocationSpec实现,也各自有不同的转换成内存地址的实现方式,这部分还是很重要的,涉及到了很多核心DWARF数据结构的使用。 我们一起来看几个示例,你就明白就了。 ##### NormalLocationSpec NormalLocationSpec表示的是 `file:line` 或者 `func:line` 这种类型的位置描述,注意它包含了一个FuncLocationSpec用以支持 `func:line` 这种情况,FuncLocationSpec并没有实现 LocationSpec interface。 OK,我们来看下这个函数是如何实现的。 ```go // NormalLocationSpec represents a basic location spec. // This can be a file:line or func:line. type NormalLocationSpec struct { Base string FuncBase *FuncLocationSpec LineOffset int } // FuncLocationSpec represents a function in the target program. type FuncLocationSpec struct { PackageName string AbsolutePackage bool ReceiverName string PackageOrReceiverName string BaseName string } // Find will return a list of locations that match the given location spec. // This matches each other location spec that does not already have its own spec // implemented (such as regex, or addr). func (loc *NormalLocationSpec) Find(t *proc.Target, processArgs []string, scope *proc.EvalScope, locStr string, includeNonExecutableLines bool, substitutePathRules [][2]string) ([]api.Location, string, error) { // 如果是file:line描述方式,所有后缀匹配的文件都算是候选文件,我们需要先找到候选的源文件列表 // - 但是这里的候选文件可能比较多,所以必须加个数量限制,如果没有开发者想要的候选文件,那就得指定的路径更明确点 // - 再一个是源文件路径映射的问题,这里需要根据路径映射规则进行映射,以免匹配不到 limit := maxFindLocationCandidates var candidateFiles []string for _, sourceFile := range t.BinInfo().Sources { substFile := sourceFile if len(substitutePathRules) > 0 { substFile = SubstitutePath(sourceFile, substitutePathRules) } if loc.FileMatch(substFile) || (len(processArgs) >= 1 && tryMatchRelativePathByProc(loc.Base, processArgs[0], substFile)) { candidateFiles = append(candidateFiles, sourceFile) if len(candidateFiles) >= limit { break } } } limit -= len(candidateFiles) // 如果是func:line描述方式,所有后缀匹配的函数名都算是候选函数,我们也得先找到候选的函数列表 // - 这里的候选函数可能也比较多,所以也得加个数量限制,如果没有开发者想要的候选函数,那也得指定的函数名更明确点, // 比如包含包路径、receivertype var candidateFuncs []string if loc.FuncBase != nil && limit > 0 { // 查找最多limit个函数名匹配的函数 // - 先查泛型函数 (Go 的泛型在编译时会为不同的类型参数生成不同的具体实现,这些实现可能都对应到同一行源码???) // how generics works? see: https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md // - 再查其他普通函数 candidateFuncs = loc.findFuncCandidates(t.BinInfo(), limit) } // 如果没有找到匹配的源文件名、函数名 if matching := len(candidateFiles) + len(candidateFuncs); matching == 0 { // 如果没有指定作用域,那么直接返回未找到错误 if scope == nil { return nil, "", fmt.Errorf("location %q not found", locStr) } // 注意,file:line, func:line这里的line是可选项,想象下添加断点时,对吧! // 简化下,如果输入了 xxx,但是当做func去查找时没有查到,有可能是少输入了符号* …… 所以当做 *xxx 重新解析下 addrSpec := &AddrLocationSpec{AddrExpr: locStr} locs, subst, err := addrSpec.Find(t, processArgs, scope, locStr, includeNonExecutableLines, nil) if err != nil { return nil, "", fmt.Errorf("location %q not found", locStr) } return locs, subst, nil } else if matching > 1 { // 如果找到了多个匹配,调试器不知道在哪里添加断点,需要提示开发者位置有歧义 return nil, "", AmbiguousLocationError{Location: locStr, CandidatesString: append(candidateFiles, candidateFuncs...)} } var addrs []uint64 var err error // 如果候选源文件只有1个,下面看下有没有line要求 if len(candidateFiles) == 1 { // 行号只能>=0,解析NormalLocationSpec时,LineOffset初始化为-1 if loc.LineOffset < 0 { return nil, "", errors.New("Malformed breakpoint location, no line offset specified") } // 通过DWARF行号表查找 file:line 对应的指令地址, addrs, err = proc.FindFileLocation(t, candidateFiles[0], loc.LineOffset) } else { // 如果候选函数只有1个,下面看下有没有line要求,这个其实要分两步来完成 // - 先找到函数入口地址对应的源码行(file:line) // - newLine=line+LineOffset,使用 file:newLine 作为位置,查行号表得到地址 addrs, err = proc.FindFunctionLocation(t, candidateFuncs[0], loc.LineOffset) } ... return []api.Location{addressesToLocation(addrs)}, "", nil } ``` ##### LineLocationSpec LineLocationSpec描述的是当前源文件的指定行的位置,当前源文件位置的确定依赖scope.PC+DWARF行号表,这样先确定当前PC所处的源码位置"文件名:行号",然后确定新的文件名行号"文件名:loc.Line"。然后再通过行号表将其转换为对应的PC地址。 关于DWARF行号表的设计实现,如果你忘记了相关的细节,可以翻翻 [DWARF行号表](8-dwarf/5-other/2-lineno-table.md)。 ```go // LineLocationSpec represents a line number in the current file. type LineLocationSpec struct { Line int } // Find will return the location at the given line in the current file. func (loc *LineLocationSpec) Find(t *proc.Target, _ []string, scope *proc.EvalScope, _ string, includeNonExecutableLines bool, _ [][2]string) ([]api.Location, string, error) { // 由于需要确定当前执行到的源码行位置,依赖PC,所以参数EvalScope不能为空。 if scope == nil { return nil, "", errors.New("could not determine current location (scope is nil)") } // 确定当前执行到的源文件位置,只关心文件名,行号已经重新指定 file, _, fn := scope.BinInfo.PCToLine(scope.PC) if fn == nil { return nil, "", errors.New("could not determine current location") } // 确定新的位置file:loc.Line subst := fmt.Sprintf("%s:%d", file, loc.Line) // 查找源文件位置对应的指令地址 addrs, err := proc.FindFileLocation(t, file, loc.Line) if includeNonExecutableLines { if _, isCouldNotFindLine := err.(*proc.ErrCouldNotFindLine); isCouldNotFindLine { return []api.Location{{File: file, Line: loc.Line}}, subst, nil } } return []api.Location{addressesToLocation(addrs)}, subst, err } ``` >注意,同一行源代码,可能对应了多条机器指令,那么该使用哪一个指令地址应该作为该源码行的第一条指令呢?比如用来添加断点时,应该停在哪一条指令处? > >在行号表中每一行都有一列标识,是否将该行指令当做源码行添加断点时的指令。这个是很重要的,比如Go里面的函数调用是非常特殊的,它不同于C、C++,Go函数调用开始会先检查栈帧大小是否够用,不够用会会执行栈扩容动作,扩容完成再返回原来的函数执行。如果在函>数调用的第一条指令处添加断点,我们会观察到这个函数执行了两次,这很奇怪!所以,对于Go语言调试器,通常要将函数入口处栈检查通过后的第一条指令位置当做断点位置。 但是这并不是 `LocationSpec.Find(...) ([]api.Location, _, error)` 会返回多个位置的理由?上面的问题,DWARF中已经解决了,只需要compiler、linker、debugger开发者注意即可。Find操作返回多个位置的一个情景是,Go Generics,Go泛型函数是通过一种称为"stenciling(蜡印)"的技术,即会为每种泛型参数生成一个函数实例,这多个实例的入口地址自然是不同的,所以这个情景下就存在一个file:line位置存在多个api.Location的可能性。 在介绍NormalLocationSpec查找候选函数名的时候,我们有提到过,会优先搜索泛型函数名,再搜索其他普通函数名,了解即可。 ##### OffsetLocationSpec 当前调试器执行到的源码行file:line,在当前源代码位置,增加一个行偏移量LineOffset,得到新的位置file:line+LineOffset。 ```go // OffsetLocationSpec represents a location spec that // is an offset of the current location (file:line). type OffsetLocationSpec struct { Offset int } // Find returns the location after adding the offset amount to the current line number. func (loc *OffsetLocationSpec) Find(t *proc.Target, _ []string, scope *proc.EvalScope, _ string, includeNonExecutableLines bool, _ [][2]string) ([]api.Location, string, error) { // 因为要确定当前执行到的源代码位置,依赖PC,所以scope必须有效 if scope == nil { return nil, "", errors.New("could not determine current location (scope is nil)") } // 根据PC确定当前执行到的源文件位置 file:line, fn file, line, fn := scope.BinInfo.PCToLine(scope.PC) if loc.Offset == 0 { subst := "" if fn != nil { subst = fmt.Sprintf("%s:%d", file, line) } return []api.Location{{PC: scope.PC}}, subst, nil } if fn == nil { return nil, "", errors.New("could not determine current location") } // 确定新的源文件位置 file:line+LineOffset subst := fmt.Sprintf("%s:%d", file, line+loc.Offset) // 确定新位置对应的指令地址 addrs, err := proc.FindFileLocation(t, file, line+loc.Offset) ... return []api.Location{addressesToLocation(addrs)}, subst, err } ``` ##### AddrLocationSpec AddrLocationSpec其实支持了如下几种方式: - `
`,直接指定了一个地址 - `*
`,表达式形式指定了一个地址 - ``,函数本身也算是一个地址?函数序言之后的第一条指令的地址 ```go // AddrLocationSpec represents an address when used // as a location spec. type AddrLocationSpec struct { AddrExpr string } // Find returns the locations specified via the address location spec. func (loc *AddrLocationSpec) Find(t *proc.Target, _ []string, scope *proc.EvalScope, locStr string, includeNonExecutableLines bool, _ [][2]string) ([]api.Location, string, error) { // locStr 本身包含的是一个地址,如locStr=0x12345678 if scope == nil { addr, _ := strconv.ParseInt(loc.AddrExpr, 0, 64) return []api.Location{{PC: uint64(addr)}}, "", nil } // locStr可能是一个表达式,如 *0x12345678 or 0x12345678+0x20 v, _ := scope.EvalExpression(loc.AddrExpr, proc.LoadConfig{FollowPointers: true}) switch v.Kind { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: addr, _ := constant.Uint64Val(v.Value) return []api.Location{{PC: addr}}, "", nil case reflect.Func: fn := scope.BinInfo.PCToFunc(v.Base) pc, _ := proc.FirstPCAfterPrologue(t, fn, false) return []api.Location{{PC: pc}}, v.Name, nil default: return nil, "", fmt.Errorf("wrong expression kind: %v", v.Kind) } } ``` 这里分两种情况:本身就是一个地址值,直接字符串转Int64后返回;另一种是一个表达式,`scope.EvalExpression(...)`,表达式结果可以是一个计算出的地址,也可能是一个函数,如果是后者,那么就需要取函数prologue后的第一条指令地址。 ps: scope.EvalExpression的工作原理,我们在前一小节 [19-表达式计算](./19-how_evalexpr_works.md) 中进行了详细介绍。如果你忘记了它是如何工作的,可以翻回去看看。当然这一节并没有对所有类型的表达式进行计算,但是我们已经介绍了读者了解这些的所有必备知识、关键流程,读者可以自行了解。 ##### RegexLocationSpec 通过 `/regexp/` 的格式来配置一个正则表达式,所有函数名与该正则匹配的位置,都会作为候选函数,然后找到这些函数对应的指令地址。 ```go type RegexLocationSpec struct { FuncRegex string } // Find will search all functions in the target program and filter them via the // regex location spec. Only functions matching the regex will be returned. func (loc *RegexLocationSpec) Find(t *proc.Target, _ []string, scope *proc.EvalScope, locStr string, includeNonExecutableLines bool, _ [][2]string) ([]api.Location, string, error) { if scope == nil { //TODO(aarzilli): this needs only the list of function we should make it work return nil, "", errors.New("could not determine location (scope is nil)") } funcs := scope.BinInfo.Functions matches, err := regexFilterFuncs(loc.FuncRegex, funcs) if err != nil { return nil, "", err } r := make([]api.Location, 0, len(matches)) for i := range matches { addrs, _ := proc.FindFunctionLocation(t, matches[i], 0) if len(addrs) > 0 { r = append(r, addressesToLocation(addrs)) } } return r, "", nil } ``` ### 执行测试 略 ### 本节小结 本文详细介绍了符号级调试器中locspec(位置描述符)的解析与地址转换机制。locspec允许开发者使用直观的字符串表示位置信息,如"文件名:行号"、"函数名"、"正则表达式"等,而不需要直接操作内存地址。文章首先定义了locspec的文法规范,支持多种位置描述方式,然后通过具体的Go代码实现展示了如何将位置描述字符串解析为不同的LocationSpec类型(如NormalLocationSpec、LineLocationSpec、OffsetLocationSpec等),并详细说明了每种类型如何通过Find方法将位置描述转换为实际的内存地址。整个实现涉及DWARF调试信息的解析、行号表查找、函数符号匹配等核心调试技术,为调试器提供了用户友好的位置描述方式和位置定位功能。 ### 参考文献 1. how go generics works, https://github.com/golang/proposal/blob/master/design/generics-implementation-dictionaries-go1.18.md 2. ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/21-debug_disassemble.md ================================================ ## Disassemble 这一节我们先介绍这个命令分组中的第一各命令 `disassemble` 的设计及实现。 ```bash (tinydbg) help ... Viewing source and disassembly, Listing pkgs, funcs, types: disassemble (alias: disass) Disassembler. funcs ---------------------- Print list of functions. list (alias: ls | l) ------- Show source code. packages ------------------- Print list of packages. types ---------------------- Print list of types ... ``` ### 实现目标: `disassemble [-a ,end>] [-l ]` ```bash (tinydbg) help disass Disassembler. [goroutine ] [frame ] disassemble [-a ] [-l ] If no argument is specified the function being executed in the selected stack frame will be executed. -a disassembles the specified address range -l disassembles the specified function ``` 解释下这里的反汇编命令选项: - `-a `:指定要反汇编的指令地址范围,由于x86指令集是变长编码,不是固定长度编码,意味着指令地址不是某个数字的整数倍,输入这个地址范围的时候必须是准确的,如果错了就可能得到错误的反汇编指令,或者反汇编出错。但是可以借助DWARF调试信息,比如查看某个函数的开始、结束指令地址范围,然后再指定。当然了有了函数名后,直接用 `-l ` 就可以了,也不一定有这个必要使用 `-a ` 这种形式了; - `-l `:这个是指定位置,tinydbg中定义了一系列的描述位置的方式,常见的比如函数名,文件名:行号,这些都是合法的locspec,详细的受支持的locspec列表我们会在接下来介绍。 程序在执行过程中,具体到某个线程、协程,它执行过程中,一定是有自己的系统栈、协程栈的,协程的函数调用栈,要么在这个协程栈上,要么特殊场景涉及到部分系统栈上,但是不管怎么样,总是有个调用栈的。而每个调用栈都对应着一个函数调用,而且我们根据Call Frame Information,是可以拿到调用栈中任意一个栈帧对应的函数信息的,包含函数的指令地址范围。这样就方便了,我们可以直接执行 `disass` 不带任何参数,此时会直接将当前goroutine当前在执行函数的当前语句行进行反汇编。`goroutine `, `frame ` 表示在输入 `disass` 之前的调试活动,我们已经切换到了goroutine n,并且选中了栈帧frame m。 ### 基础知识 在实现指令级调试器时,我们介绍了一些反汇编相关的知识。这里在符号级调试器中实现反汇编,所不同的一个是我们反汇编操作指定地址的方式,不仅仅有尅直接指定地址范围的 `disass a `,还有 `disass -l `。在前一节我们介绍了支持的locspec类型以及它们是如何结合进程上下文信息将字符串位置描述转换为内存地址的。 在接下来的反汇编操作中,少不了要用到,解析locspec,并利用它来转换为内存地址的操作 …… 再然后,才是从指定内存地址处开始反汇编的动作。 ### 代码实现 #### client端实现 client端核心代码路径如下: ```bash disassembleCmd.cmdFn \--> disassCommand(t *Session, ctx callContext, args string) error \--> parse command flag `-a ` and `-l ` \--> read disassemble flavor **intel**, **go**, **plan9** \--> if no flag `-a` or `-l` specified, then disassemble **current statement** \--> locs, _, _ := t.client.FindLocation(ctx.Scope, "+0", true, t.substitutePathRules()) \--> instructions, _ = t.client.DisassemblePC(ctx.Scope, locs[0].PC, flavor) \--> if `-a ` specified, then disassemble **range start,end** \--> instructions, _ = t.client.DisassembleRange(ctx.Scope, uint64(startpc), uint64(endpc), flavor) \--> if `-l ` specified, then \--> locs, _, _ := t.client.FindLocation(ctx.Scope, locspec, true, t.substitutePathRules()) \--> instructions, _ = t.client.DisassemblePC(ctx.Scope, locs[0].PC, flavor) \--> disasmPrint, print the instructions ``` 下面看下各部分实现: 反汇编命令disass对应的command定义,其执行逻辑就是disassCommand: ```go var disassembleCmd = func(c *DebugCommands) *command { return &command{ aliases: []string{"disassemble", "disass"}, cmdFn: disassCommand, group: sourceCmds, helpMsg: `Disassembler. [goroutine ] [frame ] disassemble [-a ] [-l ] If no argument is specified the function being executed in the selected stack frame will be executed. -a disassembles the specified address range -l disassembles the specified function`, } } ``` 看下这个disassCommand的执行逻辑: ```go func disassCommand(t *Session, ctx callContext, args string) error { // cmd其实就是flag `-a` or `-l`, rest为flag对应的选项值 var cmd, rest string if args != "" { argv := config.Split2PartsBySpace(args) cmd = argv[0] rest = argv[1] } // 获取反汇编偏好,intel, go, gnu格式汇编 flavor := t.conf.GetDisassembleFlavour() var disasm api.AsmInstructions var disasmErr error // 根据指定的选项,进行反汇编处理 switch cmd { case "": // 未指定选项,则反汇编当前函数当前语句 locs, _, err := t.client.FindLocation(ctx.Scope, "+0", true, t.substitutePathRules()) if err != nil { return err } disasm, disasmErr = t.client.DisassemblePC(ctx.Scope, locs[0].PC, flavor) case "-a": // -a start end,反汇编这个地址范围内的指令 v := config.Split2PartsBySpace(rest) if len(v) != 2 { return errDisasmUsage } startpc, err := strconv.ParseInt(v[0], 0, 64) if err != nil { return fmt.Errorf("wrong argument: %q is not a number", v[0]) } endpc, err := strconv.ParseInt(v[1], 0, 64) if err != nil { return fmt.Errorf("wrong argument: %q is not a number", v[1]) } disasm, disasmErr = t.client.DisassembleRange(ctx.Scope, uint64(startpc), uint64(endpc), flavor) case "-l": // -l locspec,反汇编这个位置描述处的指令 locs, _, err := t.client.FindLocation(ctx.Scope, rest, true, t.substitutePathRules()) if err != nil { return err } if len(locs) != 1 { return errors.New("expression specifies multiple locations") } disasm, disasmErr = t.client.DisassemblePC(ctx.Scope, locs[0].PC, flavor) default: return errDisasmUsage } if disasmErr != nil { return disasmErr } // 打印汇编指令 disasmPrint(disasm, t.stdout, true) return nil } ``` 观察上面的函数实现,会发现 client要实现反汇编,一来几个基础的RPC操作: ```go // FindLocation returns concrete location information described by a location expression // loc ::= : | [:] | // | (+|-) | | *
// * can be the full path of a file or just a suffix // * ::= .. | .(*). | . | . | (*). | // * must be unambiguous // * // will return a location for each function matched by regex // * + returns a location for the line that is lines after the current line // * - returns a location for the line that is lines before the current line // * returns a location for a line in the current file // * *
returns the location corresponding to the specified address // NOTE: this function does not actually set breakpoints. // If findInstruction is true FindLocation will only return locations that correspond to instructions. FindLocation(scope api.EvalScope, loc string, findInstruction bool, substitutePathRules [][2]string) ([]api.Location, string, error) // DisassemblePC disassemble code of the function containing PC DisassemblePC(scope api.EvalScope, pc uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) // DisassembleRange disassemble code between startPC and endPC DisassembleRange(scope api.EvalScope, startPC, endPC uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) ``` - FindLocation操作支持查找指定locspec对应的指令地址,有可能对应多个指令地址,这个好理解,比如用regexp匹配了多个函数,每个函数对应一个入口指令地址。 - DisassemblePC,对包含此PC的函数,这个要靠调试器后端查找CIE列表找到CIE,然后再在这个CIE包含的FDE列表找到对应的FDE,然后确定对应的函数的起始地址,然后可以从PC到结束地址处的指令进行反汇编。 - DisassembleRange,这个直接指定了start,end地址,需要调试器后端读取进程内存start,end中的指令数据,然后进行反汇编。输入地址可能包含无效地址,不过无所谓,读内存报错 or 反汇编报错,直接返回错误就ok。 这几个操作的详细实现,我们会在server端实现部分予以介绍。 #### client端RPC client端发起RPC调用的时候,第一个参数都是 `api.EvalScope`,这个向服务器指明了接下来的操作要在哪个协程、栈帧 or defer函数栈帧中执行。 举几个例子: - `args`,打印函数参数,当然得知道哪个goroutine执行的函数,不同协程执行该函数时、该函数被调多次调用时参数可能不同; - `locals`,打印局部变量,当然也得知道不同goroutine执行的函数,而且同一个函数局部变量值在不同协程中执行时可能会不同; - `print `,打印表达式的值,表达式中可能包含变量名,相同变量名可能在不同函数中、全局函数中定义,自然需要知道是哪个函数定义的,需要这个作用域来进一步确定定义的位置,进而确定变量类型、存储位置; - etc. see: tinydbg/service/api/types.go: api.EvalScope ```go // EvalScope is the scope a command should // be evaluated in. Describes the goroutine and frame number. type EvalScope struct { GoroutineID int64 Frame int DeferredCall int // when DeferredCall is n > 0 this eval scope is relative to the n-th deferred call in the current frame } ``` OK,介绍清楚了api.EvalScope,我们来看下反汇编期间,client端使用到的几个RPC调用。这里我们主要是了解下它的请求参数,也就了解了这个RPC可被调用的场景。 **FindLocation**: ```go type FindLocationIn struct { Scope api.EvalScope Loc string IncludeNonExecutableLines bool // SubstitutePathRules is a slice of source code path substitution rules, // the first entry of each pair is the path of a directory as it appears in // the executable file (i.e. the location of a source file when the program // was compiled), the second entry of each pair is the location of the same // directory on the client system. SubstitutePathRules [][2]string } type FindLocationOut struct { Locations []api.Location SubstituteLocExpr string // if this isn't an empty string it should be passed as the location expression for CreateBreakpoint instead of the original location expression } // Location holds program location information. // In most cases a Location object will represent a physical location, with // a single PC address held in the PC field. // FindLocations however returns logical locations that can either have // multiple PC addresses each (due to inlining) or no PC address at all. type Location struct { PC uint64 `json:"pc"` File string `json:"file"` Line int `json:"line"` Function *Function `json:"function,omitempty"` PCs []uint64 `json:"pcs,omitempty"` PCPids []int `json:"pcpids,omitempty"` } func (c *RPCClient) FindLocation(scope api.EvalScope, loc string, findInstructions bool, substitutePathRules [][2]string) ([]api.Location, string, error) { var out FindLocationOut err := c.call("FindLocation", FindLocationIn{scope, loc, !findInstructions, substitutePathRules}, &out) return out.Locations, out.SubstituteLocExpr, err } ``` 注意这了查找指令地址时,有个参数 FindLocationIn.IncludeNonExecutableLines会被设置为false,目的是排除空行、注释等不包含可执行指令的源码行。 **DisassemblePC**: ```go type DisassembleIn struct { Scope api.EvalScope StartPC, EndPC uint64 Flavour api.AssemblyFlavour } type DisassembleOut struct { Disassemble api.AsmInstructions } // DisassemblePC disassembles function containing pc func (c *RPCClient) DisassemblePC(scope api.EvalScope, pc uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) { var out DisassembleOut err := c.call("Disassemble", DisassembleIn{scope, pc, 0, flavour}, &out) return out.Disassemble, err } ``` **DisassembleRange**: ```go // DisassembleRange disassembles code between startPC and endPC func (c *RPCClient) DisassembleRange(scope api.EvalScope, startPC, endPC uint64, flavour api.AssemblyFlavour) (api.AsmInstructions, error) { var out DisassembleOut err := c.call("Disassemble", DisassembleIn{scope, startPC, endPC, flavour}, &out) return out.Disassemble, err } ``` DisassemblePC 和 DisassembleRange 的区别,对应的服务端实现其实是同一个接口 `(s *RPCServer) Disassemble(...)`,区别只是DisassembleIn.EndPC是否为0. OK,客户端调用的RPC我们介绍完了,接下来介绍下服务器侧是如何实现上述RPC操作的。 #### server端实现 **FindLocation**: server端的FindLocation实现,其实就是前面咱们介绍过的locspec的内容,涉及到客户端输入的locspec的解析,解析成具体的LocationSpec实现之后,再用它来执行查找 `LocationSpec.Find(....)`,拿到找到的指令地址信息[]*api.Location。locspec小节我们也举了几个不同的LocationSpec实现是如何来查找对应的指令地址的。这部分内容我们将在 [20-locspec解析与地址转换](./20-how_locspec_works.md) 进行想介绍,感兴趣的话,你也可以先睹为快。 ```go // FindLocation returns concrete location information described by a location expression. // // loc ::= : | [:] | // | (+|-) | | *
// * can be the full path of a file or just a suffix // * ::= .. | .(*). | . | . | (*). | // must be unambiguous // * // will return a location for each function matched by regex // * + returns a location for the line that is lines after the current line // * - returns a location for the line that is lines before the current line // * returns a location for a line in the current file // * *
returns the location corresponding to the specified address // // NOTE: this function does not actually set breakpoints. func (s *RPCServer) FindLocation(arg FindLocationIn, out *FindLocationOut) error { var err error out.Locations, out.SubstituteLocExpr, err = s.debugger.FindLocation( arg.Scope.GoroutineID, arg.Scope.Frame, arg.Scope.DeferredCall, arg.Loc, arg.IncludeNonExecutableLines, arg.SubstitutePathRules) return err } // FindLocation will find the location specified by 'locStr'. func (d *Debugger) FindLocation(goid int64, frame, deferredCall int, locStr string, ...) { ... loc, _ := locspec.Parse(locStr) return d.findLocation(goid, frame, deferredCall, locStr, loc, includeNonExecutableLines, substitutePathRules) } func (d *Debugger) findLocation(goid int64, frame, deferredCall int, locStr string, locSpec locspec.LocationSpec, includeNonExecutableLines bool, substitutePathRules [][2]string, ) ([]api.Location, string, error) { locations := []api.Location{} t := proc.ValidTargets{Group: d.target} subst := "" for t.Next() { pid := t.Pid() s, _ := proc.ConvertEvalScope(t.Target, goid, frame, deferredCall) // 不同的LocationSpec有不同的Find实现 locs, s1, _ := locSpec.Find(t.Target, d.processArgs, s, locStr, includeNonExecutableLines, substitutePathRules) if s1 != "" { subst = s1 } for i := range locs { if locs[i].PC == 0 { continue } file, line, fn := t.BinInfo().PCToLine(locs[i].PC) locs[i].File = file locs[i].Line = line locs[i].Function = api.ConvertFunction(fn) locs[i].PCPids = make([]int, len(locs[i].PCs)) for j := range locs[i].PCs { locs[i].PCPids[j] = pid } } locations = append(locations, locs...) } return locations, subst, nil } ``` 现在我们拿到了输入的位置描述字符串locspec对应的指令地址了,然后咱们就可以从这个指令地址处开始读取进程指令数据,然后按照指定的汇编flavor开始反汇编了(反汇编到函数结束)。当然了,我们可能直接通过 `-a ` 指定了地址范围,那咱们就读取进程内存地址 `` 范围内的数据,然后开始反汇编就可以了。 **Disassemble**: ```go // Disassemble code. // // If both StartPC and EndPC are non-zero the specified range will be disassembled, otherwise the function containing StartPC will be disassembled. // // Scope is used to mark the instruction the specified goroutine is stopped at. // // Disassemble will also try to calculate the destination address of an absolute indirect CALL if it happens to be the instruction the selected goroutine is stopped at. func (s *RPCServer) Disassemble(arg DisassembleIn, out *DisassembleOut) error { insts, err := s.debugger.Disassemble(arg.Scope.GoroutineID, arg.StartPC, arg.EndPC) if err != nil { return err } out.Disassemble = make(api.AsmInstructions, len(insts)) for i := range insts { out.Disassemble[i] = api.ConvertAsmInstruction( insts[i], s.debugger.AsmInstructionText(&insts[i], proc.AssemblyFlavour(arg.Flavour))) } return nil } // Disassemble code between startPC and endPC. // if endPC == 0 it will find the function containing startPC and disassemble the whole function. func (d *Debugger) Disassemble(goroutineID int64, addr1, addr2 uint64) ([]proc.AsmInstruction, error) { ... if addr2 == 0 { fn := d.target.Selected.BinInfo().PCToFunc(addr1) if fn == nil { return nil, fmt.Errorf("address %#x does not belong to any function", addr1) } addr1 = fn.Entry addr2 = fn.End } g, err := proc.FindGoroutine(d.target.Selected, goroutineID) if err != nil { return nil, err } curthread := d.target.Selected.CurrentThread() if g != nil && g.Thread != nil { curthread = g.Thread } regs, _ := curthread.Registers() return proc.Disassemble(d.target.Selected.Memory(), regs, d.target.Selected.Breakpoints(), d.target.Selected.BinInfo(), addr1, addr2) } ``` see: tinydbg/pkg/dwarf/op/regs.go 这里先看下这个,DwarfRegisters记录了CFA计算需要用到的信息,这里面的寄存器是些伪寄存器,不一定对应着真实物理寄存器。执行CFA相关的计算时,你还有印象吗?当我们有了调用栈信息表,给我们一个指令地址PC,我们执行CIE.initial_instructions,然后执行找到对应的FDE并执行FDE.instructions,一直执行到nextPC>该指令地址PC,我们的DwarfRegisters里就得到了帧地址、函数返回地址等信息。 ```go / DwarfRegisters holds the value of stack program registers. type DwarfRegisters struct { StaticBase uint64 CFA int64 FrameBase int64 ObjBase int64 regs []*DwarfRegister ByteOrder binary.ByteOrder PCRegNum uint64 SPRegNum uint64 BPRegNum uint64 LRRegNum uint64 ChangeFunc RegisterChangeFunc FloatLoadError error // error produced when loading floating point registers loadMoreCallback func() } ``` see: tinydbg/pkg/proc/disasm.go ```go // Disassemble disassembles target memory between startAddr and endAddr, marking // the current instruction being executed in goroutine g. // If currentGoroutine is set and thread is stopped at a CALL instruction Disassemble // will evaluate the argument of the CALL instruction using the thread's registers. // Be aware that the Bytes field of each returned instruction is a slice of a larger array of size startAddr - endAddr. func Disassemble(mem MemoryReadWriter, regs Registers, breakpoints *BreakpointMap, bi *BinaryInfo, startAddr, endAddr uint64) ([]AsmInstruction, error) { if startAddr > endAddr { return nil, fmt.Errorf("start address(%x) should be less than end address(%x)", startAddr, endAddr) } return disassemble(mem, regs, breakpoints, bi, startAddr, endAddr, false) } func disassemble(memrw MemoryReadWriter, regs Registers, breakpoints *BreakpointMap, bi *BinaryInfo, startAddr, endAddr uint64, singleInstr bool) ([]AsmInstruction, error) { // 需要用物理寄存器值来初始化它,后面执行CFA计算逻辑时会用到并更新这里的值 var dregs *op.DwarfRegisters if regs != nil { dregs = bi.Arch.RegistersToDwarfRegisters(0, regs) } // 从内存读取指令数据 mem := make([]byte, int(endAddr-startAddr)) _, err := memrw.ReadMemory(mem, startAddr) if err != nil { return nil, err } r := make([]AsmInstruction, 0, len(mem)/bi.Arch.MaxInstructionLength()) pc := startAddr var curpc uint64 if regs != nil { curpc = regs.PC() } for len(mem) > 0 { // 检查下一条待decode的指令开头字节是否是0xCC,是的话表明之前添加了断点,先恢复原始指令数据 // 反汇编完了之后,再恢复添加断点 bp, atbp := breakpoints.M[pc] if atbp { copy(mem, bp.OriginalData) } // 根据指令地址拿到源文件位置,这个表是根据DWARF行号表建立起来的 file, line, fn := bi.PCToLine(pc) // 反汇编指令 var inst AsmInstruction inst.Loc = Location{PC: pc, File: file, Line: line, Fn: fn} inst.Breakpoint = atbp inst.AtPC = (regs != nil) && (curpc == pc) bi.Arch.asmDecode(&inst, mem, dregs, memrw, bi) r = append(r, inst) // 下一条待decode的指令地址 pc += uint64(inst.Size) mem = mem[inst.Size:] // 如果是decode单条指令的化,就可以结束了 if singleInstr { break } } return r, nil } ``` 另外这里有一层抽象设计,不同处理器架构有不同的指令集,这里的Arch.asmDecode是一个函数引用,对应着不同处理器架构上的实现。 #### target层实现 哎对了,现代调试器的前后端分离式架构中,调试器后端的服务层,符号层,目标层,前面locspec之类查找指令地址的操作就是符号层逻辑,而与目标操作系统、硬件架构相关的就属于target层了。 比如: - 反汇编,与指令集架构相关; - 进程的执行控制,不同硬件、指令集断点指令也不一样,比如amd64架构下是0xcc,有些有硬件断点,有些没有; - 进程的内存读写,不同操作系统可能系统调用也不同; - ... 这些与具体的操作系统、硬件架构相关的(GOOS/GOARCH) 就在目标层进行实现,我们的demo tinydbg只支持 linux/amd64 组合。 see: tinydbg/pkg/proc/amd64_arch.go ```go // AMD64Arch returns an initialized AMD64 // struct. func AMD64Arch(goos string) *Arch { return &Arch{ Name: "amd64", ptrSize: 8, maxInstructionLength: 15, breakpointInstruction: amd64BreakInstruction, // 断点操作 breakInstrMovesPC: true, derefTLS: goos == "windows", prologues: prologuesAMD64, fixFrameUnwindContext: amd64FixFrameUnwindContext, switchStack: amd64SwitchStack, regSize: amd64RegSize, RegistersToDwarfRegisters: amd64RegistersToDwarfRegisters, addrAndStackRegsToDwarfRegisters: amd64AddrAndStackRegsToDwarfRegisters, DwarfRegisterToString: amd64DwarfRegisterToString, inhibitStepInto: func(*BinaryInfo, uint64) bool { return false }, asmDecode: amd64AsmDecode, // 反汇编操作 PCRegNum: regnum.AMD64_Rip, SPRegNum: regnum.AMD64_Rsp, BPRegNum: regnum.AMD64_Rbp, ContextRegNum: regnum.AMD64_Rdx, asmRegisters: amd64AsmRegisters, RegisterNameToDwarf: nameToDwarfFunc(regnum.AMD64NameToDwarf), RegnumToString: regnum.AMD64ToName, debugCallMinStackSize: 256, maxRegArgBytes: 9*8 + 15*8, argumentRegs: []int{regnum.AMD64_Rax, regnum.AMD64_Rbx, regnum.AMD64_Rcx}, } } ``` 这里我们先收一下,看下它的反汇编操作是如何实现的: ```go func amd64AsmDecode(asmInst *AsmInstruction, mem []byte, regs *op.DwarfRegisters, memrw MemoryReadWriter, bi *BinaryInfo) error { return x86AsmDecode(asmInst, mem, regs, memrw, bi, 64) } // AsmDecode decodes the assembly instruction starting at mem[0:] into asmInst. // It assumes that the Loc and AtPC fields of asmInst have already been filled. func x86AsmDecode(asmInst *AsmInstruction, mem []byte, regs *op.DwarfRegisters, memrw MemoryReadWriter, bi *BinaryInfo, bit int) error { inst, err := x86asm.Decode(mem, bit) if err != nil { asmInst.Inst = (*x86Inst)(nil) asmInst.Size = 1 asmInst.Bytes = mem[:asmInst.Size] return err } asmInst.Size = inst.Len asmInst.Bytes = mem[:asmInst.Size] patchPCRelX86(asmInst.Loc.PC, &inst) asmInst.Inst = (*x86Inst)(&inst) asmInst.Kind = OtherInstruction switch inst.Op { case x86asm.JMP, x86asm.LJMP: asmInst.Kind = JmpInstruction case x86asm.CALL, x86asm.LCALL: asmInst.Kind = CallInstruction case x86asm.RET, x86asm.LRET: asmInst.Kind = RetInstruction case x86asm.INT: asmInst.Kind = HardBreakInstruction } asmInst.DestLoc = resolveCallArgX86(&inst, asmInst.Loc.PC, asmInst.AtPC, regs, memrw, bi) return nil } `x86asm.Decode(...)` 是在 `golang.org/x/arch/x86/x86asm/decode.go` 中实现的,这了我们知道就行。 ``` 题外话,现在要从0到1实现一个反汇编器disassembler,是一个非常庞大的工程,为了解决这个难题capstone反汇编引擎诞生。感兴趣的可以看下 [capstone](https://www.capstone-engine.org/) 这个项目,现在也有开发者将其PORT到了[Go Gapstone](https://github.com/bnagy/gapstone)。我们这里使用的是 `golang.org/x/arch/x86/x86asm` 这个包,大家了解即可,学习过《计算机组成原理》的对变长指令编码、解码应该都不陌生。我们只是提一下工程上,要实现一个反汇编器并不容易。 > From [Dissecting Go Binaries](https://www.grant.pizza/blog/dissecting-go-binaries/): > > First of all, in order to build a disassembler we need to know what all of the binary machine code translates to in assembly instructions. To do this we must have a reference for all assembly instructions for the architecture of the compiled binary. If you’re not familiar with this task you wouldn’t think it’d be so difficult. However, there are multiple micro-architectures, assembly syntaxes, sparsely-documented instructions, and encoding schemes that change over time. If you want more analysis on why this is difficult I enjoy this article. > > Thankfully all of the heavy lifting has been done for us by the authors and maintainers of Capstone, a disassembly framework. Capstone is widely accepted as the standard to use for writing disassembly tools. Reimplementing it would be quite a daunting, albeit educational, task so we won’t be doing that as part of this post. Using Capstone in Go is as simple as importing its cleverly named Go bindings, gapstone. #### 最后一步 OK,当调试器后端,将locspec转换成指令地址,然后读取出指令数据,并根据特定架构的指令反汇编函数进行反汇编之后,我们就得到了一系列的汇编指令。这些汇编指令列表,就这样最终返回给了客户端。客户端只需要遍历指令,然后打印出来即可。 ```go // AsmInstructions is a slice of single instructions. type AsmInstructions []AsmInstruction // AsmInstruction represents one assembly instruction. type AsmInstruction struct { Loc Location DestLoc *Location Bytes []byte Breakpoint bool AtPC bool Size int Kind AsmInstructionKind Inst archInst } func disasmPrint(dv api.AsmInstructions, out io.Writer, showHeader bool) { bw := bufio.NewWriter(out) defer bw.Flush() if len(dv) > 0 && dv[0].Loc.Function != nil && showHeader { fmt.Fprintf(bw, "TEXT %s(SB) %s\n", dv[0].Loc.Function.Name(), dv[0].Loc.File) } tw := tabwriter.NewWriter(bw, 1, 8, 1, '\t', 0) defer tw.Flush() for _, inst := range dv { atbp := "" if inst.Breakpoint { atbp = "*" } atpc := "" if inst.AtPC { atpc = "=>" } fmt.Fprintf(tw, "%s\t%s:%d\t%#x%s\t%x\t%s\n", atpc, filepath.Base(inst.Loc.File), inst.Loc.Line, inst.Loc.PC, atbp, inst.Bytes, inst.Text) } } ``` 打印操作将服务器返回的汇编指令进行打印,操作码、操作数,并进行适当的缩进、对齐操作。`disass` 的完整处理过程到此就结束了。 ### 执行测试 略。 ### 本节小结 我们详细介绍了tinydbg中的反汇编操作的实现,前后端分离式架构下调试器前后端之间的服务层通信,不同位置描述locspec的解析,调试器根据locspec对应的LocationSpec实现将locspec转换为指令地址,然后读取内存指令数据、反汇编,将反汇编后的指令数据返回给客户端、客户端打印显示出来。这个完整的过程我们已经全部介绍到了,相信大家对这块的理解也更深入了。 ### 参考文献 1. Capstone, https://www.capstone-engine.org/ 2. Gapstone, https://github.com/bnagy/gapstone 3. Dissecting Go Binaries, https://www.grant.pizza/blog/dissecting-go-binaries/ ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/22-debug_breakpoint_part1.md ================================================ ## Breakpoint part1: 现代调试器断点精细化管理 ### 前言 断点是调试器能力的核心功能之一,在介绍指令级调试器时,我们详细介绍过断点的底层工作原理。如果你忘记了指令0xCC的作用,忘记了 `ptrace(PTRACE_PEEKDATA/POKEDATA/PEEKTEXT/POKETEXT, ...)` 的功能,忘记了处理器执行0xCC后会发生什么,忘记了Linux内核如何响应SIGTRAP信号,忘记了子进程状态变化如何通过SIGCHLD通知到父进程并__wake_up_parent唤醒阻塞在wait4上的ptracer,甚至忘记了ptracer调用wait4是用来干什么的 …… 那我建议读者可以先翻到 [第6章 动态断点](../../6-develop-inst-debugger/6-breakpoint.md) 小节快速回顾一下。 除此以外,作为我们本节及后续小节的前置内容,我们已经介绍了: - 位置描述locspec:符号级调试器添加断点时,可以使用locspec支持的所有位置描述类型; - 表达式求值evalexpr:条件断点,其实是普通断点+条件表达式,当断点命中后,tracer会检查该断点关联的条件表达式是否成立,如果不成立会立即恢复tracee的执行; - 反汇编操作disass:符号级调试器中对特定指令地址添加断点,也是支持的,但是我们可能需要先借助反汇编操作对指定源码位置进行反汇编,看到对应的指令列表后再确定加断点的地址; 本节内容不会再重复上述底层细节,而是会将精力聚焦在现代调试器中对断点的精细化管理上,包括逻辑断点与物理断点,物理断点重叠点与Breaklets,硬件断点与软件断点。调试器gdb、lldb等也是采用了与本节内容相仿的设计,所以当你掌握了本节内容,可以很自信地说掌握了现代调试器的断点精细化管理。 OK,接下来我们一起看看符号级调试断点管理这块存在哪些挑战,以及如何通过层次化、精细化管理来解决这些挑战。 ### 定点停车的艺术 大家在坐地铁时,都有注意到列车车门会通过“定点停车”相关的技术让列车车门与站台精准对齐,以方便乘客上下车。大家早已见怪不怪,大家有没有想过,调试器如何做到“定点停车”? 读者可能想到了调试器支持各种类型的位置描述locspec,对,它提供了描述源码位置 or 指令地址的方式。单纯就源码位置而言,每一行源码可能对应着1个or多个表达式、1个or多个语句,每个表达式、语句有会对应着多条机器指令 …… 那1个源码位置对应着那么多条指令,究竟应该在哪些指令地址处添加断点呢? 这涉及到DWARF行号表一个十分关键的设计 `lineEntry.IsStmt`,每行源码对应哪些机器指令,这个编译器生成指令时早就确定了,并且编译器知道在哪条指令地址处设置断点更加合适,所以会在对应行号表中记录对应指令的 `lineEntry.IsStmt=true` 。当指定了locspec实例,并解析对应的断点位置列表时,就需要通过 `lineEntry.IsStmt=true` 对lineEntries进行筛选,筛选出来的每个lineEntry都对应着一个候选断点地址lineEntry.Addr。 然后,在这些地址处通过指令patch添加断点,或者通过调试寄存器添加断点,也就实现了定点停车。 ### 执行到下行源码 #### 执行一行源码:next 以next操作为例,它表示要执行到下一行,那如何确定下一行是哪一行呢? - 对于顺序执行的代码,可以从当前PC确定行号,然后line++直到找到下一个包含可执行指令的行(跳过注释、空行等),在该处设置断点即可。 - 对于代码包含分支控制(if-else、switch-case)和循环控制(for、break、continue)时,还可以继续line++来寻找吗? 明显不行!下面这个示例比较容易说明这点:此时简单地递增行号是错误的。因为程序包含了分支控制、跳转,程序可能跳转回forloop判断语句或特定LABEL处,行号并不是简单地 `+1` ,下一行的行号可能变大、变小。 ```go 10 var uploaded int 11 UploadNextFile: 12 for _, f := range files { 13 _, err := uploader.Upload(f) 14 if err != nil { 15 if err == APIExceedLimit { 16 slog.Error("exceed api limit ... quit") 17 break UploadNextFile // <= 执行next,应该执行到 line 29 18 } 19 if err == APIBadFileFormat { 20 slog.Error("bad file format ... try next") 21 continue // <= 执行next,应该执行到 line 12 22 } 23 slog.Error("unknown error ... quit") 24 break UploadNextFile 25 } 26 slog.Info("upload success", slog.String("file", f.Name)) // <= 执行next,应该执行到 line 12 27 } 28 29 println("uploaded files:", uploaded) // <= 执行next,应该执行到 line 30 30 println("total files:" len(files)) ``` DWARF行号表支持通过PC查询对应源码行,但无法直接获取"下一行要执行的源码",那我们如何才能做到这点呢? | 方案列表 | 执行操作 | 是否可行 | 原因 | | -------- | ----------------------------------------------------------------------------------------- | -------- | --------------------------------------------------------------- | | 方案1 | 从当前PC开始顺序扫描指令,
直到找到对应行号不同的PC位置 | 否 | 要频繁读取内存指令数据,还必须对jmp、call指令进行分析,非常低效 | | 方案2 | 通过AST分析函数体,
识别并处理各类控制流,
在控制流的分支判断表达式处均添加断点 | 否 | 需要做复杂的AST分析,且容易受Go语言版本演进中AST变化的影响 | | 方案3 | ? | ? | ? | 方案1、方案2工程中都是难以落地的,有没有更简单有效的办法呢?有! 我们换个思路,用 **一种更简单高效的“广撒网”的方式**,而不是只在要执行的下一行源码处设置断点: 1. 执行next操作时,首先确定当前PC; 2. 进而确定当前PC所属的函数,通过函数FDE确定函数的指令地址范围 [low,high); 3. 然后在行号表中筛选出这样的lineEntries: - lineEntry地址必须是在函数指令地址范围[low,high)内; - 并且lineEntry.IsStmt=true; 4. 在筛选出的lineEntries的lineEntry.Addr处添加断点; 以 `for i:=0; i<10; i++ {...}` 为例,编译工具链生成DWARF行号表时,会为 i:=0、i<10、i++ 这几个位置处的指令生成对应的lineEntries,每个位置都存在一个lineEntry满足lineEntry.IsStmt=true。调试器可以在这些entries的Addr处设置断点。这样我们就能确保在循环执行过程中正确地停在 i:=0、i++、i<10 这几个位置,而不是直接执行到forloop循环体之后的位置。 这种方法,不仅可以解决上述forloop的问题,即使源码中包含了break、continue、break LABEL、continue LABEL,也同样奏效。 #### 进入退出函数:stepin, stepout stepin和stepout的实现也需要自动隐式创建断点: - stepin, 函数入口地址可以从函数定义对应的DWARF DIE获取, - stepout, 而返回地址则需要通过DWARF调用帧信息(CFA)进行计算, 这样执行stepin、stepout时,在相应位置设置好断点位置,并continue执行到断点位置即可。 #### Go定制化需要:StackSplit go为了支持协程栈伸缩,编译器在函数序言部分安插了一些栈检查指令,go函数调用时首先进行栈大小检查,如果栈大小不够用了,就会创建一个更大的栈,并将当前栈上的数据copy过去,然后调整goroutine的一些硬件上下文信息,也包括将goroutine的栈指向这个新的栈。这个过程俗称 "**栈分裂 stacksplit**"。当完成上述过程后,需要通过跳转指令重新跳转回指令函数地址开头,然后重新开始执行。 stacksplit特殊在哪里?为什么需要调试器特别关注?不妨思考下stepin时应该在函数入口哪个指令地址处添加断点。函数开头的第一条指令?第二条指令?or others? 如果我们在第一条指令 or 第二条指令处添加断点,那么我们大概率会观察到一个函数被调用了两次,很诡异。实际上我们应该停在stacksplit、callee保存rbp并重新更新rbp之后的第一条指令位置处。类似地,Go调试器需要对此做特殊处理。 以下面源码为例,我们来说明下特殊处理的必要性,然后执行 `go build -o main -gcflags 'all=-N -l' main.go` 完成构建: ```go 01 package main 02 03 func main() { 04 var a int = 1 05 var b int = 2 06 var c int 07 08 c = Add(a, b) 09 println(c) 10 } 11 12 func Add(a, b int) int { 13 return a + b 14 } ``` 接下来我们使用 radare2 (r2) 来演示下go函数反汇编后指令执行、跳转流程,很明显可以看到main.main开头存在一个栈检查、栈分裂过程: ``` $ r2 ./main [0x00470b60]> s sym.main.main [0x00470ae0]> af [0x00470ae0]> pdf ┌ 103: sym.main.main (); │ afv: vars(3:sp[0x10..0x20]) │ ┌─> 0x00470ae0 493b6610 cmp rsp, qword [r14 + 0x10] // main.main入口地址 │ ┌──< 0x00470ae4 7659 jbe 0x470b3f // 如果栈空间不够,则跳转到0x004700b3f执行stacksplit │ │╎ 0x00470ae6 55 push rbp │ │╎ 0x00470ae7 4889e5 mov rbp, rsp │ │╎ 0x00470aea 4883ec28 sub rsp, 0x28 // <== 栈分裂+callee保存并设置帧基址后,这个地址更适合用做断点 │ │╎ 0x00470aee 48c7442420.. mov qword [var_20h], 1 │ │╎ 0x00470af7 48c7442418.. mov qword [var_18h], 2 │ │╎ 0x00470b00 48c7442410.. mov qword [var_10h], 0 │ │╎ 0x00470b09 b801000000 mov eax, 1 │ │╎ 0x00470b0e bb02000000 mov ebx, 2 │ │╎ 0x00470b13 e848000000 call sym.main.Add │ │╎ 0x00470b18 4889442410 mov qword [var_10h], rax │ │╎ 0x00470b1d 0f1f00 nop dword [rax] │ │╎ 0x00470b20 e85b8bfcff call sym.runtime.printlock │ │╎ 0x00470b25 488b442410 mov rax, qword [var_10h] │ │╎ 0x00470b2a e8f191fcff call sym.runtime.printint │ │╎ 0x00470b2f e88c8dfcff call sym.runtime.printnl │ │╎ 0x00470b34 e8a78bfcff call sym.runtime.printunlock │ │╎ 0x00470b39 4883c428 add rsp, 0x28 │ │╎ 0x00470b3d 5d pop rbp │ │╎ 0x00470b3e c3 ret │ └──> 0x00470b3f 90 nop │ ╎ 0x00470b40 e89badffff call sym.runtime.morestack_noctxt.abi0 // stacksplit └ └─< 0x00470b45 eb99 jmp sym.main.main // 当stacksplit准备ok后,重新跳转回main.main执行 [0x00470ae0]> ``` 前面我们提过了,为了避免栈分裂导致的同一个函数被调用两次的假象,我们不应该在栈检查相关的几条指令位置添加断点,如 `0x00470ae0` `0x00470ae4` 这几条都是不合适的,但是偏偏go编译器生成行号表的时候,将 `0x00470ae0` 对应的lineEntry.IsStmt设置为了true,意味着调试器应该将此为止作为一个断点位置。 你可以通过dwarfviewer来查看行号表,下面是截取的一部分main.main开头的指令对应的行号表lineEntries: ``` Address Line File Column IsStmt Basic Block 0x00470ae0 3 /home/zhangjie/debugger101/test/go_func/main.go 0 true false // 这个位置不合适,栈分裂时会导致同一个函数被执行两次的假象 0x00470aea 3 /home/zhangjie/debugger101/test/go_func/main.go 0 true false // 这个位置可以! 0x00470aee 4 /home/zhangjie/debugger101/test/go_func/main.go 0 true false 0x00470af7 5 /home/zhangjie/debugger101/test/go_func/main.go 0 true false ``` 其实将 `0x00470ae0` 作为候选断点位置不太合适,至少对普通开发者来说是不合适的。但是对于运行时调试人员,比如你想跟踪stacksplit,那么在 `0x00470ae0` 设置断点就是合适的。所以也可以理解成go编译器开发人员给了调试器设计人员一定的自由度,你可以通过一个选项来打开对stacksplit的跟踪(在0x00470ae0设置断点),默认不跟踪stacksplit(在0x00470aea设置断点)。 > 关于如何使用dwarfviewer查看行号表? > > 执行命令 `dwarfviewer -file=./main -view=line -webui`, 然后使用浏览器打开webui( http://localhost:8080 ),注意从webui左侧侧边栏中选择编译单元 `main`,便可以查看该编译单元的行号表。 ### 隐式断点操作 `next` `stepin` `stepout` 符号级调试器中,用户会通过执行 `break` 命令显式创建的断点,也有些调试命令会隐式地自动创建断点,比如 `next` `stepin` `stepout`。 我们这里比较下step和next,step表示步进单条指令,next表示执行到下一行源码处: - 在指令级调试器中,step命令通过 `ptrace(PTRACE_SINGLESTEP, ...)`开启CPU单步执行模式来实现指令级别的步进。本质上是CPU控制的,CPU发现flags是步进模式,那么就执行一条指令后触发一个DebugException,该异常处理程序中会让tracee停下来,并通知tracer来观察tracee状态。 - 而在符号级调试器中,要实现next源码行级别的步进操作,就需要智能地确定要执行到的下个源码行是哪一行,并在此处设置对应的断点,然后continue到此断点处,如果是0xCC软件断点则触发BreakpointException,该异常处理程序中会让tracee停下来,并通知tracer来观察tracee状态。通过这种方式来实现源码行级别的步进。 stepin和stepout的实现也需要自动隐式创建断点: - stepin, stepin一个函数时,需要在函数入口地址处添加断点,然后continue到断点处; - stepout, 从一个函数中stepout时,需要在函数返回地址处添加断点,然后continue到断点处; **为了方便在源码级别进行调试,符号级调试器会隐式地自动创建断点**。设想下,如果没有next自动地断点管理机制,要实现执行一行源码的操作,我们还需要手动走读下代码判断下下一行源码位置是跳转到line10还是line20,然后再手动添加断点,再continue …… 这得多不方便。 ### 隐式断点细分 为了更好地进行断点管理,需要区分人工手动创建、隐式自动创建的断点,对后者还需要再进一步细分其创建的情景、原因。当上述两类断点在同一个源码位置多次添加时、重叠时,我们需要精细化控制这个断点的行为。 ```go // BreakpointKind determines the behavior of debugger when the breakpoint is reached. type BreakpointKind uint16 const ( // 用户执行break命令创建的断点 UserBreakpoint BreakpointKind = (1 << iota) // 用户执行next命令时隐式创建的断点 NextBreakpoint // ... NextDeferBreakpoint // ... StepBreakpoint // ... WatchOutOfScopeBreakpoint // ... StackResizeBreakpoint // ... PluginOpenBreakpoint // 用户执行stepin命令时隐式创建的断点 StepIntoNewProcBreakpoint // ... NextInactivatedBreakpoint // ... StepIntoRangeOverFuncBodyBreakpoint // 该bitset表示步进操作断点的所有类型 steppingMask = NextBreakpoint | NextDeferBreakpoint | StepBreakpoint | StepIntoNewProcBreakpoint | NextInactivatedBreakpoint | StepIntoRangeOverFuncBodyBreakpoint ) ``` ### 逻辑断点 vs 物理断点 我们期望在指定的一个源代码位置处添加断点,在个别特殊场景下,为了达到这个断点效果,我们要在多个不同机器指令地址处添加断点。 考虑如下几种常见情况: - Go泛型函数 `func Add[T ~int|~int32|~int64](a, b T) T {return a+b;}`,如果程序中使用了 `Add(1,2), Add(uint(1), uint(2))` 那么这个泛型函数就会为int、uint分别实例化两个函数(了解下Go泛型函数实现方案,gcshaped stenciling)。继续转成机器指令后,泛型函数内同一个源码行自然就对应着两个地址(一个是int类型实例化位置,一个是uint类型实例化位置)。 - 对于函数内联,其实也存在类似的情况。满足内联规则的小函数,在多个源码位置多次调用,编译器将其内联处理后,函数体内同一行源码对应的指令被复制到多个调用位置处,也存在同一个源码行对应多个地址的情况。 实际上我们执行 `break locspec` 添加断点的时候,我们压根不会去考虑泛型函数如何去实例化成多个、哪些函数会被内联。即使知道也肯定不想用泛型函数实例化后的指令地址、函数内联后的地址去逐个设置断点 …… 因为这样非常不方便。 **“在1个源码位置添加断点,实际上需要在泛型实例化、内联后的多个指令地址处创建断点”**,为了描述这种关系,就有了**“逻辑断点” 和 “物理断点”** 的概念: - 逻辑断点:`break 源码位置`,通过这种方式创建断点,会在对应源码位置处创建1个逻辑断点; - 物理断点:逻辑断点强调的是源代码位置,物理断点强调的是底层实现时要用断点指令0xCC进行指令patch,一个逻辑断点对应着至少1个物理断点。 泛型函数多次实例化、满足内联规则的函数多次内联时,1个逻辑断点会对应着多个物理断点。 实际日常调试过程中,我们说“添加断点”强调的是更容易感知的“逻辑断点”,底层实现时会涉及1个或多个“物理断点”的创建。 ### 断点重叠管理 breaklet 先举个例子,比如有下面代码片段: 假定当前我们现在停在11行这个位置,现在我们执行 `break 12` 那么将会在12行创建一个逻辑断点,对应的也会创建1个物理断点,然后我们执行 `next`操作来逐源码行执行。next操作会确定当前函数范围,并为函数内所有指令地址对应的lineEntry.IsStmt的指令地址处增加一个断点,12行也不例外。 此时,在12行就出现了两个创建逻辑断点的操作,一个是人为 `break 12`设置的,一个是 `next` 隐式创建的。这里的两个逻辑断点,最终也是要去设置物理断点的,但是我们怎么明确表示这个地址处实际上是有两个“物理断点”发生了重叠呢? ```go 10 for { => 11 i++ 12 println(i) 13 } ``` 重叠意味着什么呢?物理断点最终是否生效,需要综合重叠的多个断点的创建逻辑、断点激活条件来判断。举个例子,比如某个逻辑断点命中n次后才触发激活,或者命中超过m次后就不再激活,调试期间执行到此断点时tracee也停下了,但是tracer发现条件不满足断点激活条件,tracer便会执行PTRACE_CONT操作恢复tracee执行。但是,假如当前断点位置有next隐式创建的断点,那么实际上这个断点处还是应该停下来,因为next操作设计预期就是如此,它比条件断点的条件判断优先级还要高。 那怎么描述这种同一个物理断点处存在多个断点的重叠呢?这就要引入 `Breaklet` 抽象。 OK,截止到这里,我们可以抛出逻辑断点、物理断点、Breaklet三者的层次关系了: ```go // 1个逻辑断点包含多个物理断点,解决的是泛型函数、函数内联情况下, // 一个源码位置处添加逻辑断点对应多个机器指令位置添加物理断点的问题 type LogicalBreakpoint struct { breakpoints []*Breakpoint ... } // 1个物理多点包含至少1个breaklets,解决的是描述多个断点在同一个物理地址处重叠的问题 type Breakpoint struct { breaklets []*Breaklet ... } // Breaklet表示多个在同一个物理断点处重叠的多个断点之一 type Breaklet struct { // 表示是否是一个步进断点(next、step) Kind BreakpointKind // 当前物理断点归属的逻辑断点的ID LogicalID int // 如果不为nil,Cond表达式求值为true时该断点才会激活, // 不激活的意思就是调试器会发现tracee触发断点后,会主动执行continue让tracee继续执行 Cond ast.Expr // 当这个breaklet的所有条件都满足时,触发这个回调,这个回调逻辑允许包含带副作用的逻辑, // 返回true时表示这个断点是active状态 callback func(th Thread, p *Target) (bool, error) ... } ``` Ok,结合上面伪代码,现在我们可以简单总结下: - 同一个逻辑断点可能对应着多个物理断点,因为Go支持泛型函数、函数内联; - 同一个物理断点可能有多个breaklets,因为多个断点在同一个物理断点处会出现重叠; - 每个breaklet表示在同一个物理断点处重叠的多个断点之一 - 它有独立的断点类型Kind来区分每个断点添加原因; - 它有标识字段体现它实际上从属于哪一个逻辑断点; - 每个breaklet有自己的激活条件; - 每个breaklet的所有条件满足时,有自己的callback可以触发执行; ### 软件断点 vs 硬件断点 调试器实现断点的方式主要有两种:软件断点和硬件断点。 - 软件断点是通过断点指令0xCC进行指令patch,软件断点相对来说使用更普遍,兼容性更好,但会修改目标程序指令。 - 硬件断点则是利用CPU提供的调试寄存器(如x86的DR0-DR7)来实现的,不需要修改指令,能监视代码执行和数据访问,但调试寄存器数量有限。 #### 软件断点 不同处理器架构有不同的断点指令,以x86为例,其断点指令是0xCC(INT 3)。 当处理器执行了指令0xCC后,处理器会产生#BP异常(向量号3,BreakpointException),内核捕获该异常并通知调试器。 #### 硬件断点 以x86架构为例,提供了4个调试地址寄存器(DR0-DR3)和2个调试控制寄存器(DR6-DR7)来支持硬件断点。 当设置一个硬件断点时,需要执行如下操作: 1. 将断点地址写入某个未使用的DR0-DR3寄存器 2. 在DR7中设置对应的控制位: - L0-L3位: 启用对应的DR0-DR3断点(置1启用) - G0-G3位: 全局启用对应断点(置1启用) - R/W0-R/W3位: 设置断点类型 - 00: 执行断点 - 01: 数据写入断点 - 11: 数据读写断点 - LEN0-LEN3位: 设置监视的数据长度(1/2/4/8字节) 当程序执行到断点地址或访问监视的内存时,处理器会产生#DB异常(向量号1, DebugException),内核捕获该异常并通知调试器。 #### 异常处理 无论是软件断点产生的#BP异常,还是硬件断点产生的#DB异常,产生异常后,处理器会立即转入对该异常的处理。具体流程是:处理器从中断描述符表(IDT)中找到该异常对应的处理程序入口地址,然后跳转到该异常处理程序去执行。这里的IDT是在内核启动时设立的,每个异常对应的处理程序都是Linux内核提供的。 Linux内核的异常处理程序执行时,会生成SIGTRAP信号发送给tracee。比较特殊的是,SIGTRAP信号的处理也是内核负责的,内核会进一步通知tracer。tracer此时一般处于wait4阻塞等待状态,等内核通过SIGCHLD信号和__wake_up_parent机制唤醒tracer后,tracer就可以使用ptrace相关操作去获取tracee的运行时状态了。 > **术语澄清**:严格来讲,"中断"这个术语可以泛指异常(exception)、陷阱(trap)、外部中断(interrupt),也可以专指外部中断。外部中断是外设通过中断控制器连接到CPU的INTR引脚产生的,CPU在每个指令执行周期结束时通过中断控制器(负责中断屏蔽、按优先级扫描设备中断)来感知接下来需要处理哪个外设的中断请求。外部中断的处理是异步的,而上述提及的软件断点通过安插指令来生成异常并处理的方式是同步的。 > > -Exception(异常):通常由程序错误或CPU检测到的特殊情况产生(如除零、无效指令、页错误等),向量号0-31。其实0-31中的异常可以细分为faults可恢复的异常, traps调试相关, aborts不可恢复的三大类。 > -Trap(陷阱):是一种特殊的异常,通常用于调试(如#DB, #BP),陷阱处理后会返回到异常指令的下一条。 > -Interrupt(中断):通常由外部设备产生(如定时器、键盘、网卡等),向量号32及以上。 ### 支持多进程+多线程调试 并发编程模型,当前无外乎多进程、多线程、协程这几种编程模型,以Go为例吧,Go暴漏给开发者的并发操作是基于goroutine的,但是goroutine执行最终还是依赖于thread。对Linux而言,thread实现其实是轻量级进程LWP,我们可以通过系统调用clone结合一些资源复制选项来创建线程。有时我们也会实现多进程程序,比如支持热重启的微服务框架。OK,调试器如果能够方便地支持对多进程、多线程、协程进行跟踪,那肯定是非常方便的。 #### tid or pid 对调试器而言,所有的被跟踪对象tracee都是就线程这个身份来说的。线程隶属于进程,`getpid()`返回的是所属进程ID,`syscall(SYS_gettid)`返回的是线程ID,这里的tid就是线程对应的LWP的pid。ptrace操作的参数pid,其实指的就是线程对应的轻量级进程的pid。ps: `/proc//tasks/` 下是进程包含的线程的tid(对应的LWP的pid)。 #### 调试困难点 多进程调试、多线程调试、协程调试的困难点: - 当父进程创建子进程时,如何自动跟踪子进程,如果需要手动加断点让子进程停下来,会错过合适的调试时机; - 当进程内部创建新线程时,如何自动跟踪新线程,如果需要手动加断点让新线程停下来,也会错过合适的调试时机; - 当跟踪某个协程goroutine-10时,continue恢复现成执行后,GMP调度器可能会调度另一个goroutine-20来执行并停在断点处,但是我们期望跟踪的是goroutine-10; 对于自动跟踪新进程、新线程,我们需要通过自动跟踪新创建的进程、线程。对于跟踪特定协程执行,我们可以借助条件断点的方式,调试器可以给断点加条件 `break [--name|-n name] [locspec] [if condition]` 相当于调试器内部隐式加个条件 `cond runtime.curg.goid == 创建断点时goid`。都可以相对简单的搞定。 #### 断点传播 我们需要进一步思考的是,断点的管理,是否需要针对线程或者进程粒度单独进行维护呢?假设我们现在正在调试的是进程P1的线程T1,调试期间我们创建了一些断点。 思考以下问题: 1. 当我们切换到进程P1的线程T2去跟踪调试的时候,你希望这些断点在T2继续生效吗? 2. 再或者进程P1 forkexec创建了子进程P2,你希望上述断点在P2也生效吗? 对于问题1,从实践角度来说,大家是倾向于添加一个断点后,这个断点对当前进程的所有线程生效,但是我们可能更倾向于跟踪当前线程,所以更希望当前线程运行到断点后停下来,而不是其他线程运行到断点后就停下来。 对于问题2,有可能父子进程运行的代码是完全一样的,也可能子进程通过exec替换了可执行的代码,对于前者,我们还是倾向于能够自动将父进程设置过的断点,让它在子进程也生效的。 对于问题1,我们不需要特殊处理,因为同一个进程中的线程执行的代码都是相同的,设置了断点之后对所有线程都是生效的。对于问题2,尤其是父子进程执行相同代码的情况,我们是希望能够将父进程中的断点给传播到子进程,让这些断点在子进程中也生效的。 #### Stop Mode 当进程执行期间命中断点时,是只让命中断点的线程停下来,还是让其内部的所有线程都停下来?这其实就是主流调试器采用的两种控制模式:All-stop Mode 和 Non-stop Mode。但是从实践来看,当触发断点时能够默认暂停整个进程中的所有活动(All-stop Mode),对调试来说是更便利的,开发者可以有更多时间去观察。 ps:默认暂停整个进程中的所有活动比较便利,但是也要同时伴之以提供恢复某个特定进程、线程的执行,这在调试某几个有协作关系、并发执行的线程时,是非常有价值的。 ##### All-stop Mode 当一个线程命中断点时,主流调试器 (如 GDB, LLDB, Delve, Visual Studio Debugger 等) 的默认行为是暂停整个进程,也就是暂停所有其他线程。这种模式通常被称为 "All-Stop Mode"。 为什么更倾向于将All-stop Mode作为默认行为?主要原因是为了保证调试会话的一致性和可预测性: 1. 冻结状态:当您在某个断点停下来时,您希望检查的是程序在“那一个瞬间”的完整状态。如果其他线程继续运行,它们可能会修改内存、改变变量值、释放资源等。这样一来,您在调试器中看到的数据可能在您查看它的下一秒就失效了,这会让调试变得几乎不可能。 2. 避免数据竞争:让其他线程继续运行会引入新的、仅在调试时才会出现的竞态条件(Race Condition),或者掩盖掉您正在试图寻找的那个竞态条件。 3. 可控的执行:当您单步执行(Step Over, Step Into)代码时,您期望只有当前线程执行一小步。如果其他线程在后台“自由飞翔”,那么程序的全局状态在您执行一步之后可能会发生天翻地覆的变化,这违背了单步调试的初衷。 当一个线程因为断点(通常是一个特殊的陷阱指令,如 x86 上的 INT 3)而暂停时: 1. CPU 产生一个异常。 2. 操作系统内核捕获这个异常,并通知正在监控(trace)这个进程的调试器。 3. 调试器接收到通知,此时它获得了控制权。 4. 调试器会立即通过操作系统接口,向该进程的其他所有线程发送一个暂停信号(如 SIGSTOP),将它们全部“冻结”住。 ##### Non-stop Mode 虽然“All-stop”是默认且最常用的模式,但现代调试器也支持另一种高级模式,称为 "Non-stop Mode"。 在 Non-stop 模式下,当一个线程命中断点时,只有该线程被暂停,其他线程可以继续运行。调试器可以独立地控制每一个线程的执行(暂停、继续、单步等)。 什么时候会使用 Non-stop 模式?这通常用于一些特殊的、复杂的调试场景: - 实时系统:比如一个线程负责UI响应,您不希望因为调试后台工作线程而导致整个界面卡死。 - 监控程序:一个线程可能需要持续地响应心跳或处理网络请求,暂停它会导致连接超时。 - 分析复杂的并发问题:您可能想观察当一个线程被“卡住”时,其他线程的行为模式。 在 GDB 中,你可以通过 `set non-stop on/off` 命令来切换这两种模式。但毫无疑问,Non-stop 模式对调试者的心智负担远大于 All-stop 模式。 ps:后续可以看到tinydbg的一些设计,为同时启停进程组内的多个进程、同一个进程内的多个线程,提供了支持。 ### Put it Together OK,上面这些统筹起来,就是现代调试器断点管理的层次结构,tinydbg的实现也借鉴了类似的设计。 ```bash TargetGroup (调试器跟踪的进程组 debugger.Debugger.target) ├── LogicalBreakpoints map[int]*LogicalBreakpoint // 调试器维护的全局逻辑断点 └── targets []proc.Target (进程组中的多个进程) ├── Target 1 (进程P1, 包含多个threads) │ └── BreakpointMap (每个进程的断点映射) │ ├── M map[uint64]*Breakpoint // 物理断点(按地址索引) | | ├── []*Breaklet // 每个物理断点又包含了一系列的Breaklet, | | 每个Breaklet有自己的Kind,Cond,etc. │ └── Logical map[int]*LogicalBreakpoint // 逻辑断点(共享引用) └── Target 2 (进程P2, 包含多个threads) └── BreakpointMap (每个进程的断点映射) ├── M map[uint64]*Breakpoint // 物理断点(按地址索引) | ├── []*Breaklet // 每个物理断点又包含了一系列的Breaklet, | 每个Breaklet有自己的Kind,Cond,etc. └── Logical map[int]*LogicalBreakpoint // 逻辑断点(共享引用) ``` tinydbg,它做到了这些方面: - **跟踪进程组的多进程与多线程**: 支持跟踪进程组中的多进程、多线程,并支持对它们的启停等进行统一管理; - **逻辑断点与物理断点层次区分**:1个逻辑断点可以对应多个物理断点,同1个物理断点处允许存在多个断点在此处重叠; - **逻辑断点全局共享,统一管理**:所有断点都是逻辑断点,在 TargetGroup 级别统一管理; 也因此,逻辑断点状态也做到了全局共享,如断点命中计数等信息在逻辑断点级别维护,所有进程、线程共享; - **自动断点传播机制,调试便利**:新进程、新线程自动继承现有的断点; 也允许通过 follow-exec 和正则表达式控制断点传播范围,如子进程cmdline匹配正则时才继承断点; - **断点实现灵活,基于软件&硬件**:根据调试场景及当时状态,选择适用的软件&硬件断点方案; - **手动创建&隐式创建完美协作**:通过breaklets解决调试人员手动创建的断点、其他调试命令隐式创建的断点重叠的问题; - **Stop-all Mode并支持切换**:在Stop-all Mode这种更直观的场景上,支持threads/goroutines切换,更好适应并发调试场景; 这使得它成为了一个非常实用的Go调试器,它也成为了现代调试器设计实现的典型代表,致敬 go-delve/delve 的贡献者们。 ### 本节小结 本文围绕现代调试器(以 go-delve/delve 为例)中断点的精细化管理展开,系统梳理了逻辑断点、物理断点、Breaklet 等多层次断点抽象,以及它们在支持泛型、内联、断点重叠等复杂场景下的作用。我们还介绍了多进程多线程调试时的断点的自动传播机制、断点启用策略(如 follow-exec 及正则过滤)、Stop-all与Non-stop Mode,以及 next/stepin/stepout 等调试命令背后的隐式断点管理。通过这些机制,调试器能够在多进程、多线程、复杂控制流下,实现灵活、精准且高效的断点控制,极大提升了调试的便利性。 本文主要聚焦于断点管理的原理和设计思想,尚未深入到具体的实现细节和源码分析。下一小节我们进一步剖析实现断点的一些关键操作,将原理与实践相结合,更好地理解和掌握现代调试器的断点管理。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/23-debug_breakpoint_part2.md ================================================ ## Breakpoint - part2: 添加断点+执行到断点 前一小节我们深入介绍了现代调试器断点精细化管理面临的挑战以及解决办法,本节我们从实现角度出发,来看一看tinydbg是如何实现常用断点操作的,以及执行到断点之后的处理。 ### 实现目标: 添加+执行到断点 本节实现目标,重点是介绍添加断点命令 `breakpoint` 的设计实现,以及执行到断点后调试器的相关处理逻辑。前一节介绍了现代调试器断点的层次化、精细化管理,引入了一些必要的设计及抽象,实现也会相对更复杂。调试器中有多个与断点机制强相关的调试命令,为了保证内容更聚焦、更便于阅读理解,本节仅介绍添加断点命令 `breakpoint` 以及 程序执行到断点后的处理逻辑。这也是断点相关设计实现中最核心的内容,理解了这部分内容后,再去理解其他调试命令就简单了,我们将在后续小节中介绍。 断点以及与断点机制相关的调试命令设计及实现,内容组织上我是这么规划的: - part1:介绍现代调试器断点的层次化、精细化管理; - part2:breakpoint命令添加断点,以及执行到断点后的处理逻辑; - part3:`breakpoint ... if expr` or `condition expr` 条件断点的创建,及断点命中后的处理逻辑; - part4:`breakpoints` `clear` `clearall` `toggle`,这几个查看、清理、关闭or激活断点的操作; - ~part5~:`trace` `watch` 这两种特殊类型的断点会在 [trace](./26-debug_trace.md) 和 [watch](./2-debug_watch.md) 中分别进行介绍。 ps: `on command` 断点命中后执行指定的动作,这个会在介绍part2、part3过程中介绍。 ### 代码实现: `breakpoint` OK,接下来我们看下 `breakpoint` 命令在clientside、serverside分别是如何实现的。 先来看看break支持的操作, `break [--name|-n=name] [locspec] [if ]`: - `--name|-n=name` 可以指定断点名字,如果调试任务比较重,涉及到大量断点,能给断点命名非常有用,它比id、断点位置更易于辨识使用; - `[locspec]` 前面介绍过的所有受支持的 `locspec`写法,`break` 命令都予以了支持,这将使得添加断点非常方便; - `[if condition]` 添加断点时还可以直接指定断点激活条件 `if `,这里的condition是任意bool类型表达式。 ps:如果断点创建时未指定激活条件,后续也可以使用 `condition ` 为已有断点指定激活条件。 ```bash (tinydbg) help break Sets a breakpoint. break [--name|-n=name] [locspec] [if ] Locspec is a location specifier in the form of: * *
Specifies the location of memory address address. address can be specified as a decimal, hexadecimal or octal number * : Specifies the line in filename. filename can be the partial path to a file or even just the base name as long as the expression remains unambiguous. * Specifies the line in the current file ... If locspec is omitted a breakpoint will be set on the current line. If you would like to assign a name to the breakpoint you can do so with the form: break -n mybpname main.go:4 Finally, you can assign a condition to the newly created breakpoint by using the 'if' postfix form, like so: break main.go:55 if i == 5 Alternatively you can set a condition on a breakpoint after created by using the 'on' command. ``` ps:tinydbg重写了go-delve/delve的clientside的断点操作,我们将相对低频使用的参数[name]调整为了选项 `--name|-n=` 的形式,这样也使得程序中解析断点name, locspec, condition的逻辑大幅简化。原来go-delve/delve中的breakpoint命令的参数、选项、条件解析逻辑,可读性欠佳,不太适合作为教程示例给大家介绍。 OK,接下来我们看看tinydbg中添加断点时,clientside、serverside的设计实现。 #### clientside 核心代码 ```bash debug_breakpoint.go:breakpointCmd.cmdFn(...), i.e., breakpoint(...) \--> _, err := setBreakpoint(t, ctx, false, args) \--> name, spec, cond, err := parseBreakpointArgs(argstr) | 解析断点相关的name,spec,cond | \--> locs, substSpec, findLocErr := t.client.FindLocation(ctx.Scope, spec, true, t.substitutePathRules()) | 查找spec对应的地址列表,注意文件路径的替换 | \--> if findLocErr != nil && shouldAskToSuspendBreakpoint(t) | 如果没找到,询问是否要添加suspended断点,后续会激活 | bp, err := t.client.CreateBreakpointWithExpr(requestedBp, spec, t.substitutePathRules(), true) | return nil, err | if findLocErr != nil | return nil, findLocErr | | ps: how shouldAskToSuspendBreakpoint(...) works: | target calls `plugin.Open(...)`, target exited, followexecmode enabled | \--> foreach loc in locs do | 对于每个找到的地址,创建断点 | requestBp.Addr = loc.PC; | requestedBp.Addrs = loc.PCs; | requestedBp.AddrPid = loc.PCPids | bp, err := t.client.CreateBreakpointWithExpr(requestedBp, spec, t.substitutePathRules(), false) | \--> if it is a tracepoint, set breakpoints for return addresses, then | 如果是添加tracepoint,那么对于locspec匹配的每个函数,都要在返回地址处设置断点 | ps: like `trace [--name|-n=name] [locspec]`, in which `locspec` matches functions | | foreach loc in locs do | if loc.Function != nil then | addrs, err := t.client.(*rpc2.RPCClient).FunctionReturnLocations(locs[0].Function.Name()) | foreach addr in addrs do | _, err = t.client.CreateBreakpoint(&api.Breakpoint{Addr: addrs[j], TraceReturn: true, Line: -1, LoadArgs: &ShortLoadConfig}) ``` 简单总结下clientside添加断点的处理流程: 1. 解析输入字符串,得到断点名name、位置描述spec、条件cond; 2. 然后请求服务器返回位置描述spec对应的指令地址列表; 3. 如果服务器查找spec失败,至少说明spec对应的位置当前没有指令数据。此时询问是否要尝试添加suspended断点,等后续指令加载后或者进程启动后就可以激活断点;如果服务器查找spec失败,也不需要添加suspended断点,那么返回失败。 4. 如果服务器查找spec成功,则将服务器返回的每个指令地址处([]api.Location)都请求添加断点; 5. 如果当前添加的是tracepoint,并且解析出的位置描述spec中还匹配了一些函数,tracepoint因为要观察func的进入、退出时状态,所以这里请求服务器返回匹配函数的返回地址列表,然后返回地址处也添加断点。 通过clientside添加断点的处理过程,我们可以粗略看出,这里处理了普通断点、条件断点、suspended断点、tracepoints 。读者朋友可以关注,clientside添加不同类型断点时用到的RPC以及RPC参数设置的差异。 > ps: 创建断点相关的几个RPC协议设计,给人感觉非常繁琐、冗余、不精炼。 > > ``` > type Client interface { > ... > // CreateBreakpoint creates a new breakpoint. > CreateBreakpoint(*api.Breakpoint) (*api.Breakpoint, error) > > // CreateBreakpointWithExpr creates a new breakpoint and sets an expression to restore it after it is disabled. > CreateBreakpointWithExpr(*api.Breakpoint, string, [][2]string, bool) (*api.Breakpoint, error) > ... > } > ``` > > 实际上api.Breakpoint描述的是一个断点在clientside希望能看到的完整信息,但是将其用于创建断点请求,让人感觉使用起来非常不方便,这个类型有29个字段,设置是哪些字段才是有效请求呢?再比如CreateBreakpointWithExpr,第2、3个参数分别是locspec以及是否是suspended bp,这俩字段本来就可以包含在api.Breakpoint内,为什么又要多此一举放外面?总之就感觉这里的API设计有点难受。 接下来我们看看服务器收到serverside的添加断点请求时是如何进行处理的。 #### serverside 核心代码 服务器端描述起来可能有点复杂,服务器侧为了应对各种调整,引入了多种层次的抽象和不同实现。前面介绍了断点层次化管理机制,这部分信息对于理解serverside处理流程非常重要。 OK,假定读者朋友们已经理解了上述内容,现在我们整体介绍下serverside添加断点的处理流程。 ```bash rpc2/server.go:CreateBreakpoint func (s *RPCServer) CreateBreakpoint(arg CreateBreakpointIn, out *CreateBreakpointOut) error { \--> err := api.ValidBreakpointName(arg.Breakpoint.Name) \--> createdbp, err := s.debugger.CreateBreakpoint(&arg.Breakpoint, arg.LocExpr, arg.SubstitutePathRules, arg.Suspended) | \--> checking: if breakpoints with the same name as requestBp.Name created before | | if created before, return error | | d.findBreakpointByName(requestedBp.Name) | \--> checking: if breakpoints with the same requestBp.ID created before | | if created before, return error | | lbp := d.target.LogicalBreakpoints[requestedBp.ID] | \--> breakpoint config, initialized based on following order | | \--> case requestedBp.TraceReturn, | | | bpcfg.PidAddrs = []proc.PidAddr{{Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr}} | | \--> case requestedBp.File != "", | | | bpcfg.File = requestBp.File | | | bpcfg.Line = requestBp.Line | | \--> requestedBp.FunctionName != "", | | | bpcfg.FunctionName = requestedBp.FunctionName | | | bpcfg.Line = requestedBp.Line | | \--> len(requestedBp.Addrs) > 0, | | | bpcfg.PidAddrs = make([]proc.PidAddr, len(requestedBp.Addrs)) | | | then, fill the bpcfg.PidAddrs with slice of PidAddr{pid,addr} | | \--> default, bpcfg.Addr = requestBp.Addr | \--> if locexpr != "", | | \--> bpcfg.Expr = func(t *proc.Target) []uint64 {...} | | \--> bpcfg.ExprString = locExpr | \--> create the logical breakpoint | | \--> `id`, allocate a logical breakpoint ID | | \--> lbp := &proc.LogicalBreakpoint{LogicalID: id, HitCount: make(map[int64]uint64)} | | \--> d.copyLogicalBreakpointInfo(lbp, requestedBp) | | | copy requestedBp.X to lbp.X | | | d.target.ChangeBreakpointCondition(lbp, requested.Cond, requested.HitCond, requested.HitCondPerG) | | \--> err = d.target.SetBreakpointEnabled(lbp, true) | | | \--> if lbp.enabled && !enabled, then | | | | lbp.enabled = false | | | | err = grp.disableBreakpoint(lbp) | | | \--> if !lbp.enabled && enabled, then | | | | lbp.enabled = true | | | | lbp.condSatisfiable = breakpointConditionSatisfiable(grp.LogicalBreakpoints, lbp) | | | | err = grp.enableBreakpoint(lbp) | | \--> return d.convertBreakpoint(lbp) \--> out.Breakpoint = *createdbp ``` 简单总结下这里的处理流程: 1. 创建断点时如果指定了name,先检查名字是否符合要求(必须是unicode字符,并且不能为纯数字)。 不符合要求直接返回失败。 2. 开始创建断点,如果指定了name,检查下这个名字是否已经被其他逻辑断点使用了。 名字被使用则返回错误。 3. 如果指定了逻辑断点ID,则检查该ID是否已经被其他逻辑断点使用了。 ID被使用则返回错误,错误中说明了使用该ID的断点位置信息, `proc.BreakpointExistsError{File: lbp.File, Line: lbp.Line}`。 4. 根据请求参数中设置断点的方式,创建断点: - 如果requestBp.TraceReturn=true,说明是tracepoint请求中还需指定地址requestBp.Addr(函数调用返回地址) ``` bpcfg.PidAddrs = []proc.PidAddr{ {Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr} } ``` - 如果requestBp.File != "", 则使用requestBp.File:requestBp.Line来创建断点 ``` bpcfg.File = requestBp.File, bpcfg.Line = requestBp.Line ``` - 如果requestedBp.FunctionName != "",则使用requestBp.FunctionName:requestBp.Line来创建断点 ``` bpcfg.FunctionName = requestBp.FunctionName, bpcfg.Line = requestBp.Line ``` - 如果 len(requestedBp.Addrs) != 0,则在目标进程的这些地址处添加断点 ``` bpcfg.PidAddrs = []proc.PidAddr{.....} ``` - 其他情况,使用requestBp.Addr来设置断点 ``` bpcfg.PidAddr = []proc.PidAddr{ {Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr} } ``` 5. 如果locExpr != "",则解析位置表达式得到LocationSpec,bpcfg.Expr实际上是个函数,执行后返回位置表达式查找到的地址列表 ``` bpcfg.Expr = func(t *proc.Target) []uint64 {...} bpcfg.ExprString = locExpr ``` 6. 更新逻辑断点的id,创建一个逻辑断点proc.LogicalBreakpoint{LogicalID: id, ...,Set: setbp, ...,File:...,Line:...,FunctionName:...,} 7. 设置逻辑断点的字段值,并更新其断点条件,copyLogicalBreakpointInfo->ChangeBreakpointCondition 8. 设置逻辑断点对应的物理断点:err = d.target.SetBreakpointEnabled(lbp, true) 9. 将逻辑断点信息转换为api.Breakpoint信息返还给客户端展示 接下来看下 `d.target.SetBreakpointEnabled(lbp, true)`,设置逻辑断点的流程,从逻辑断点到物理断点、breaklets,一起看看: ```bash err = d.target.SetBreakpointEnabled(lbp, true) \--> err = grp.enableBreakpoint(lbp) \--> for target in grp.targets, do: err := enableBreakpointOnTarget(target, lbp) | \--> addrs, err = FindFileLocation(t, lbp.Set.File, lbp.Set.Line), or | | addrs, err = FindFunctionLocation(t, lbp.Set.FunctionName, lbp.Set.Line), or | | filter the lbp.Set.PidAddrs if lbp.Set.PidAddrs[i].Pid == t.Pid(), or | | runs lbp.Set.Expr() to find the address list | \--> foreach addr in addrs, do: | p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) | | \--> t.setBreakpointInternal(logicalID, addr, kind, 0, cond) | | | \--> newBreaklet := &Breaklet{LogicalID: logicalID, Kind: kind, Cond: cond} | | | | | | | \--> if breakpoint existed at `addr`, then | | | | check this newBreaklet can overlap: | | | | 1) if no, return BreakpointExistsError{bp.File, bp.Line, bp.Addr}; | | | | 2) if yes, bp.Breaklets = append(bp.Breaklets, newBreaklet), | | | | 3) then `setLogicalBreakpoint(bp)`, and return | | | \--> else breakpoint not existed at `addr`, | | | | create a new breakpoint, so go on | | | | | | | \--> f, l, fn := t.BinInfo().PCToLine(addr) | | | | | | | \--> if it is watchtype: set hardware debug registers | | | | ... | | | \--> newBreakpoint := &Breakpoint{funcName, watchType, hwidx, file, line, addr} | | | \--> newBreakpoint.Breaklets = append(newBreakpoint.Breaklets, newBreaklet) | | | \--> err := t.proc.WriteBreakpoint(newBreakpoint) | | | | \--> if bp.WatchType != 0, then | | | | | watchtype要设置硬件断点,使用处理器自身调试器寄存器的话,这部分可以先不动 | | | | | for each thread in dbp.threads, do | | | | | err := thread.writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) | | | | | return nil | | | | \--> _, err := dbp.memthread.ReadMemory(bp.OriginalData, bp.Addr) | | | | | 其他断点类型,就要通过0xCC这种调试指令来完成 | | | | | return dbp.writeSoftwareBreakpoint(dbp.memthread, bp.Addr) | | | | | \--> _, err := thread.WriteMemory(addr, dbp.bi.Arch.BreakpointInstruction()) | | | | | \--> t.dbp.execPtraceFunc(func() { written, err = sys.PtracePokeData(t.ID, uintptr(addr), data) }) | | | \--> newBreakpoint.Breaklets = append(newBreakpoint.Breaklets, newBreaklet) | | | \--> setLogicalBreakpoint(newBreakpoint) | | | 更新逻辑断点/物理断点中个别相关的字段 ``` 看完clientside实现、serverside实现的核心代码路径,这里的逻辑还是比较清晰的吧。主要是明确这几点: - 这个逻辑断点对进程组grp中的所有进程都生效 `grp.enableBreakpoint(lbp) -> for target in grp.targets -> enableBreakpointOnTarget(target, lbp)`; - 这个逻辑断点位置,可能对应着多个机器指令地址,`FindFileLocation(...), or FindFunctionLocation, or filter from lbp.Set.PidAddrs, or runs lbp.Set.Expr() to find address` - 每个找到的机器指令地址处都需要添加物理断点 `p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) -> t.setBreakpointInternal(logicalID, addr, kind, 0, cond)` #### serverside 断点传播机制 符号级调试场景下“断点”通常指的是逻辑断点,逻辑断点在TargetGroup层次进行统一管理,当有新进程、新线程创建,加入TargetGroup时会自动继承TargetGroup中记录的断点。 这就是我们提到的,当新进程或新线程加入调试组时,断点会自动传播: ```go func (grp *TargetGroup) addTarget(p ProcessInternal, pid int, currentThread Thread, path string, stopReason StopReason, cmdline string) (*Target, error) { ... t, err := grp.newTarget(p, pid, currentThread, path, cmdline) ... // 共享逻辑断点 t.Breakpoints().Logical = grp.LogicalBreakpoints // 自动为新目标启用所有现有的逻辑断点 for _, lbp := range grp.LogicalBreakpoints { if lbp.LogicalID < 0 { continue } // 在新目标上启用断点 err := enableBreakpointOnTarget(t, lbp) ... } ... } func enableBreakpointOnTarget(p *Target, lbp *LogicalBreakpoint) error { // 根据断点类型决定在哪些地址设置物理断点 switch { case lbp.Set.File != "": // 文件行断点:在所有匹配的地址设置 addrs, err = FindFileLocation(p, lbp.Set.File, lbp.Set.Line) case lbp.Set.FunctionName != "": // 函数断点:在函数入口设置 addrs, err = FindFunctionLocation(p, lbp.Set.FunctionName, lbp.Set.Line) case len(lbp.Set.PidAddrs) > 0: // 指定进程指定地址处添加断点:过滤出目标进程为p的逻辑断点进行设置 for _, pidAddr := range lbp.Set.PidAddrs { if pidAddr.Pid == p.Pid() { addrs = append(addrs, pidAddr.Addr) } } } // 在每个地址设置物理断点 for _, addr := range addrs { _, err = p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) } } ``` #### serverside 断点传播策略 通过 follow-exec 和正则表达式控制断点传播范围。 如果打开了followExec模式,并且followExecRegexp不空,此时就会检查子进程执行的cmdline是否匹配,如果匹配就会自动追踪并进行断点传播。 ```bash target follow-exec -on // 打开follow-exec模式 target follow-exec -on "myapp.*" // 打开follow-exec模式,但是只跟踪cmdline匹配myapp.*的子进程 target follow-exec -off // 关闭follow-exec模式 ``` 处理逻辑详见: ```go type TargetGroup struct { followExecEnabled bool // 是否启用 follow-exec followExecRegex *regexp.Regexp // 正则表达式过滤器 // ... } func (grp *TargetGroup) addTarget(p ProcessInternal, pid int, currentThread Thread, path string, stopReason StopReason, cmdline string) (*Target, error) { logger := logflags.LogDebuggerLogger() if len(grp.targets) > 0 { // 检查是否启用 follow-exec if !grp.followExecEnabled { logger.Debugf("Detaching from child target (follow-exec disabled) %d %q", pid, cmdline) return nil, nil // 不跟踪子进程 } // 检查正则表达式过滤 if grp.followExecRegex != nil && !grp.followExecRegex.MatchString(cmdline) { logger.Debugf("Detaching from child target (follow-exec regex not matched) %d %q", pid, cmdline) return nil, nil // 不跟踪不匹配的进程 } } // 新进程被添加到调试组,所有现有断点会自动应用 t.Breakpoints().Logical = grp.LogicalBreakpoints for _, lbp := range grp.LogicalBreakpoints { err := enableBreakpointOnTarget(t, lbp) // 在新进程中设置断点 } } ``` #### serverside 断点状态共享 前面也曾提到过,逻辑断点统一在TargetGroup层次进行维护,断点的命中计数等信息也逻辑断点级别维护,所有进程、线程共享。 ```go // TargetGroup represents a group of target processes being debugged that // will be resumed and stopped simultaneously... type TargetGroup struct { procgrp ProcessGroup targets []*Target ... LogicalBreakpoints map[int]*LogicalBreakpoint ... } // 逻辑断点:用户概念上的断点 type LogicalBreakpoint struct { LogicalID int Set SetBreakpoint // 断点设置信息 enabled bool HitCount map[int64]uint64 // 命中计数 TotalHitCount uint64 // ... } ``` ### 代码实现:continue #### 我们从Attach开始 当成功添加完断点之后,clientside就可以执行继续执行continue命令,serverside收到请求后会恢复TargetGroup的执行,TargetGroup包含了一个进程组中的多个进程,而每个进程又包括了多个线程,那这里的continue是恢复所有的进程、线程的执行吗?当某个进程的个别线程命中断点停止执行后,其他进程、其他线程又如何处理呢?前一节介绍断点精细化管理时提到了Stop Mode分为All-stop Mode和None-stop Mode,我们一起来看下tinydbg中实现时是如何实现的。 All-stop Mode, 首先联想下 [tinydbg attach](./11-tinydbg-atach.md) 的实现,调试器attach目标进程时,会尝试跟踪跟目标进程下的所有线程。 ``` func Attach(pid int, waitFor *proc.WaitFor) (*proc.TargetGroup, error) { \-> dbp.execPtraceFunc(func() { err = ptraceAttach(dbp.pid) }) | _, _, err = dbp.wait(dbp.pid, 0) \-> tgt, err := dbp.initialize(findExecutable("", dbp.pid)) \-> cmdline, err := dbp.initializeBasic() \-> cmdline, err := initialize(dbp) \-> if err := dbp.updateThreadList(); err != nil { \-> foreach `tid` in /proc//task/* \-> if _, err := dbp.addThread(tid, tid != dbp.pid); err != nil { \-> sys.PtraceAttach(tid) ``` 假定我们Attach了之后再添加断点,现在我们理解添加断点的处理过程了,为了让程序执行到断点,我们可以执行continue操作,下面简单介绍下tinydbg如何处理continue命令。 #### clientside 核心代码 ```go continueCmd.cmdFn() \-> c.cont(t *Session, ctx callContext, args string) \-> stateChan := t.client.Continue() \-> for { \-> out := new(CommandOut) \-> err := c.call("Command", &api.DebuggerCommand{Name: api.Continue, ReturnInfoLoadConfig: c.retValLoadCfg}, &out) \-> \-> // record current state \-> state := out.State \-> ch <- &state \-> \-> // breakpoint (including normal breakpoint and bp-based tracepoint) \-> isbreakpoint := false \-> // tracepoint (bp-based tracepoints, exclude normal breakpoint) \-> istracepoint := true \-> for i := range state.Threads { \-> if state.Threads[i].Breakpoint != nil { \-> isbreakpoint = true \-> istracepoint = istracepoint && (state.Threads[i].Breakpoint.Tracepoint || state.Threads[i].Breakpoint.TraceReturn) \-> } \-> } \-> \-> // return if: \-> // - !isbreakpoint: doesn't encounter any breakpoint, i.e, target exit or other cases \-> // - !isbreakpoint || !istracepoint: encounter one normal breakpoint, not tracepoint \-> if !isbreakpoint || !istracepoint { \-> close(ch) \-> return \-> } \-> } \-> for state := range stateChan { printcontext(t, state); } \-> printPos(t, state.CurrentThread, printPosShowArrow) ``` #### serverside resume all ```go func (d *Debugger) Command(command *api.DebuggerCommand, resumeNotify chan struct{}, clientStatusCh chan struct{}) (state *api.DebuggerState, err error) { ... switch command.Name { case api.Continue: err = d.target.Continue() ... } ... } ``` 而 d.target.Continue() 具体做了哪些处理呢? ```go func (grp *TargetGroup) Continue() error ... \-> trapthread, stopReason, contOnceErr := grp.procgrp.ContinueOnce(grp.cctx) \-> for { | err := procgrp.resume() | | \-> foreach dbp in procgrop.procs | | | \-> foreach thread in dbp.threads | | | | \-> procgrp.stepInstruction(thread) | | | | | \-> if bp := t.CurrentBreakpoint.Breakpoint; bp is a hardware bp { | | | | | | t.clearHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) | | | | | | defer func() { err = t.writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex); }() | | | | | | } | | | | | \-> if bp, ok := t.dbp.FindBreakpoint(pc, false); ok means bp is software breakpoint { | | | | | | err = t.clearSoftwareBreakpoint(bp) | | | | | | defer func() { err = t.dbp.writeSoftwareBreakpoint(t, bp.Addr); } | | | | | | } | | | | | \-> err = procgrp.singleStep(t) | | | | | | for { | | | | | | t.dbp.execPtraceFunc(func() { err = ptraceSingleStep(t.ID, sig) }) | | | | | | wpid, status, err := t.dbp.waitFast(t.ID) | | | | | | \-> wpid, err := sys.Wait4(pid, &s, sys.WALL, nil) | | | | | | ps: Since Linux 4.7, the __WALL flag is automatically implied if the child is being ptraced, | | | | | | so debugger can know the all children (created by clone or non-clone) state changed. | | | | | | if t.ID== wpid { return nil } | | | | | | } | | \-> foreach dbp in procgrop.procs | | | \-> foreach thread in dbp.threads | | | | \-> thread.resume() | | | | | \-> sig := t.os.delayedSignal | | | | | \-> return t.resumeWithSig(sig) | | | | | | \-> t.os.running = true | | | | | | \-> t.dbp.execPtraceFunc(func() { err = ptraceCont(t.ID, sig) }) | } ``` 可以看到clientside每执行一次continue命令,服务器确实会让跟踪的所有暂停的进程、线程全部恢复执行。既然已经恢复执行了,接下来任意一个被跟踪的进程中的线程都可能会命中断点(可能是硬件断点,也可能是软件断点)。 那么不禁要问,当命中断点后,debugger是如何进行处理的呢? #### serverside stop all ```go func (grp *TargetGroup) Continue() error ... \-> trapthread, stopReason, contOnceErr := grp.procgrp.ContinueOnce(grp.cctx) | \-> for { | * err := procgrp.resume() | | ps: so far, all targets have been resumed to running | | | * trapthread, err := trapWait(procgrp, -1) | | \-> return trapWaitInternal(procgrp, pid, 0) | | if err != nil { return nil, proc.StopUnknown, err } | | ps: handles many events like child exit, cloned, non-cloned, | | and processing delayed signals including SIGTRAP, etc. | | | * trapthread, err = procgrp.stop(cctx, trapthread) | | \-> for dbp in procgrp.procs { | | | for thread in dbp.threads { | | | if thread running { | | | thread.stop() | | | \-> err = sys.Tgkill(t.dbp.pid, t.ID, sys.SIGSTOP) | | | ps: SIGSTOP will causes target thread stop, which can be resumed by sending SIGCONT. | | | And SIGSTOP cannot be caught. | | | } | | | } | | | } | | if err != nil { return nil, proc.StopUnknown, err } | | ps: All-stop mode, if we found one thread hit the breakpoint (or traced after thread cloned, non-cloned, etc), | | we stop all running threads. | | | * dbp := procgrp.procForThread(trapthread.ID) | | dbp.memthread = trapthread | | ps: here refresh other processes' threads' memthread to the one, which is the 1st thread.os.setbp == true. | | When reading memory, we uses this memthread pid as ptrace(PTRACE_PEEK/POKE_DATA/TEXT, ...) operations. | | | | return trapthread, proc.StopUnknown, nil | | } \-> series of state checking and clearing actions ... we skip them ``` continue操作会先全部恢复执行,然后每当遇到一个被跟踪的线程状态发生变化,就会尝试stop所有进程、线程的执行,并更新它们memthread的线程为当前trapthread,所有线程都停下来之后,如果想读取、修改内存数据、线程硬件上下文信息的时候,就可以通过ptrace来操作了。 #### serverside return state ```go func (d *Debugger) Command(command *api.DebuggerCommand, resumeNotify chan struct{}, clientStatusCh chan struct{}) (state *api.DebuggerState, err error) { ... switch command.Name { case api.Continue: err = d.target.Continue() ... } ... state, stateErr := d.state(api.LoadConfigToProc(command.ReturnInfoLoadConfig), withBreakpointInfo) ... for _, th := range state.Threads { if th.Breakpoint != nil && th.Breakpoint.TraceReturn { for _, v := range th.BreakpointInfo.Arguments { if (v.Flags & api.VariableReturnArgument) != 0 { th.ReturnValues = append(th.ReturnValues, v) } } } } ... return state, err } // Command interrupts, continues and steps through the program. func (s *RPCServer) Command(command api.DebuggerCommand, cb service.RPCCallback) { st, err := s.debugger.Command(&command, cb.SetupDoneChan(), cb.DisconnectChan()) if err != nil { cb.Return(nil, err) return } var out CommandOut out.State = *st cb.Return(out, nil) } func (cb *RPCCallback) Return(out interface{}, err error) { ... cb.s.sendResponse(cb.sending, &cb.req, &resp, out, cb.codec, errmsg) } ``` 最后,调试器将当前的被调试进程的状态信息 `state *api.DebuggerState` 返回给clientside,clientside则完成状态信息的打印: ```go continueCmd.cmdFn() \-> c.cont(t *Session, ctx callContext, args string) \-> stateChan := t.client.Continue() ... \-> for state := range stateChan { printcontext(t, state); } \-> printPos(t, state.CurrentThread, printPosShowArrow) ``` 在这之后,就可以在调试器会话中继续输入其他调试命令,如 print vars,eval expr等等,继续执行其他调试操作了。 ### 执行测试 略。 ### 本节小结 本节详细介绍了 tinydbg 调试器中 `breakpoint` 命令的实现机制,以及 `continue` 命令如何驱动程序执行并在断点处停下的完整流程。我们从客户端如何解析断点参数(名称、位置、条件)并与服务器交互,讲到服务器端如何查找指令地址、创建和管理逻辑断点与物理断点,并支持包括普通断点、条件断点、suspended 断点、tracepoint 等多种类型。服务器端采用层次化的断点管理架构,逻辑断点统一管理断点的状态和命中信息,物理断点则具体作用于指令地址,并通过自动断点传播机制保证新进程、新线程能够继承现有断点。随后,`continue` 命令会恢复所有目标进程和线程的执行,遇到断点时根据 all-stop/none-stop 模式进行调度和状态同步,最终将调试状态返回给客户端。整体设计不仅提升了断点管理的灵活性和效率,也为多进程、多线程复杂调试场景提供了坚实的基础。通过本节内容,读者可以系统理解现代调试器断点添加、传播与命中后的核心实现逻辑。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/24-debug_breakpoint_part3.md ================================================ ## Condition ### 实现目标 ### 基础知识 ### 代码实现 ### 执行测试 ### 本节小结 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/25-debug_breakpoint_part4.md ================================================ ## Breakpoint - part4: 其他调试命令 前一小节我们深入介绍了现代调试器断点精细化管理面临的挑战以及解决办法,本节我们从实现角度出发,来看一看tinydbg是如何实现常用断点操作的。 ### 实现目标: `breakpoint` `breakpoints` `clear` `clearall` `toggle` 本节实现目标,重点是介绍断点的精细化管理,以及添加断点命令 `breakpoint` 的设计实现。由于采用了断点的精细化管理措施,引入了一些必要的层次设计及抽象,代码复杂度、理解难度也随之增加。断点强相关的调试命令,为了读者阅读起来更加友好,我们不会一个小节就介绍完所有与断点强相关的指令,而是先介绍提及的几个: - breakpoint,添加断点、条件断点; - breakpoints,列出所有断点; - clear,移除指定断点; - clearall,移除所有断点; - toggle,激活、关闭指定断点; 另外几个断点相关的命令,我们会在后续小节中介绍: - condition,将已有断点转化为条件断点; - on,设置断点命中时要执行的具体动作; - trace,在指定位置设置tracepoint,本质上还是断点,命中后并打印相关位置信息,然后恢复执行; - watch,监视对某个变量或者内存地址处的读写操作,是借助硬件断点对特定地址的数据读写、指令执行来实现的; ps:说它们相关,是因为这几个命令的实现也是在断点基础上实现的,`condition` 为断点命中增加条件限制,`on` 在断点命中时执行动作,`trace` 在断点命中时打印位置信息,`watch` 相对特殊一点使用硬件断点来实现。 ### 代码实现: `breakpoint` OK,接下来我们看下 `breakpoint` 命令在clientside、serverside分别是如何实现的。 先来看看break支持的操作, `break [--name|-n=name] [locspec] [if ]`: - 可以指定断点名字,如果调试任务比较重,涉及到大量断点,能给断点命名非常有用,它比id更易于辨识使用; - 前面介绍过的所有受支持的 `locspec`写法,`break` 命令都予以了支持,这将使得添加断点非常方便; - 添加断点时还可以直接指定断点激活条件 `if `,这里的condition是任意bool类型表达式。 ps:如果断点已经创建,后续调试期间希望给这个断点加个激活条件,也是可以的,`condition `,实现方法上和 `if condition` 是相同的。 ```bash (tinydbg) help break Sets a breakpoint. break [--name|-n=name] [locspec] [if ] Locspec is a location specifier in the form of: * *
Specifies the location of memory address address. address can be specified as a decimal, hexadecimal or octal number * : Specifies the line in filename. filename can be the partial path to a file or even just the base name as long as the expression remains unambiguous. * Specifies the line in the current file ... If locspec is omitted a breakpoint will be set on the current line. If you would like to assign a name to the breakpoint you can do so with the form: break -n mybpname main.go:4 Finally, you can assign a condition to the newly created breakpoint by using the 'if' postfix form, like so: break main.go:55 if i == 5 Alternatively you can set a condition on a breakpoint after created by using the 'on' command. ``` ps:我们重写了tinydbg的clientside的断点操作,我们将相对低频使用的参数[name]调整为了选项 `--name|-n=`的形式,这样也使得程序中解析断点name, locspec, condition的逻辑大幅简化。 OK,接下来我们看看断点命令的执行细节。 #### clientside 实现 ```bash debug_breakpoint.go:breakpointCmd.cmdFn(...), i.e., breakpoint(...) \--> _, err := setBreakpoint(t, ctx, false, args) \--> name, spec, cond, err := parseBreakpointArgs(argstr) | 解析断点相关的name,spec,cond | \--> locs, substSpec, findLocErr := t.client.FindLocation(ctx.Scope, spec, true, t.substitutePathRules()) | 查找spec对应的地址列表,注意文件路径的替换 | \--> if findLocErr != nil && shouldAskToSuspendBreakpoint(t) | 如果没找到,询问是否要添加suspended断点,后续会激活 | bp, err := t.client.CreateBreakpointWithExpr(requestedBp, spec, t.substitutePathRules(), true) | return nil, nil | if findLocErr != nil | return nil, findLocErr | | ps: how shouldAskToSuspendBreakpoint(...) works: | target calls `plugin.Open(...)`, target exited, followexecmode enabled | \--> foreach loc in locs do | 对于每个找到的地址,创建断点 | bp, err := t.client.CreateBreakpointWithExpr(requestedBp, spec, t.substitutePathRules(), false) | \--> if it is a tracepoint, set breakpoints for return addresses, then | 如果是添加tracepoint,那么对于locspec匹配的每个函数,都要在返回地址处设置断点 | ps: like `trace [--name|-n=name] [locspec]`, in which `locspec` matches functions | | foreach loc in locs do | if loc.Function != nil then | addrs, err := t.client.(*rpc2.RPCClient).FunctionReturnLocations(locs[0].Function.Name()) | foreach addr in addrs do | _, err = t.client.CreateBreakpoint(&api.Breakpoint{Addr: addrs[j], TraceReturn: true, Line: -1, LoadArgs: &ShortLoadConfig}) ``` 简单总结下clientside添加断点的处理流程: 1. 解析输入字符串,得到断点名name、位置描述spec、条件cond; 2. 然后请求服务器返回位置描述spec对应的指令地址列表; 3. 如果服务器查找spec失败,至少说明spec对应的位置当前没有指令数据。此时询问是否要尝试添加suspended断点,等后续指令加载后或者进程启动后就可以激活断点;如果服务器查找spec失败,也不需要添加suspended断点,那么返回失败。 4. 如果服务器查找spec失败,则将服务器返回的每个指令地址处都请求添加断点; 5. 如果当前添加的是tracepoint,并且解析出的位置描述spec中还匹配了一些函数,tracepoint因为要观察func的进入、退出时状态,所以这里请求服务器返回匹配函数的返回地址列表,然后返回地址处也添加断点。 通过clientside添加断点的处理过程,我们可以粗略看出,这里处理了普通断点、条件断点、suspended断点、tracepoints 。读者朋友可以关注,clientside发起的RPC操作时不同断点情况下的请求参数设置的差异。 > ps: 创建断点相关的几个RPC协议设计,给人感觉非常繁琐、冗余、不精炼。 > > ``` > type Client interface { > ... > // CreateBreakpoint creates a new breakpoint. > CreateBreakpoint(*api.Breakpoint) (*api.Breakpoint, error) > > // CreateBreakpointWithExpr creates a new breakpoint and sets an expression to restore it after it is disabled. > CreateBreakpointWithExpr(*api.Breakpoint, string, [][2]string, bool) (*api.Breakpoint, error) > ... > } > ``` > > 实际上api.Breakpoint描述的是一个断点在clientside希望能看到的完整信息,但是将其用于创建断点请求,让人感觉使用起来非常不方便,这个类型有29个字段,设置是哪些字段才是有效请求呢?再比如CreateBreakpointWithExpr,第2、3个参数分别是locspec以及是否是suspended bp,这俩字段本来就可以包含在api.Breakpoint内,为什么又要多此一举放外面?总之就感觉这里的API设计有点难受。 接下来我们看看服务器收到serverside的添加断点请求时是如何进行处理的。 #### serverside 实现 服务器端描述起来可能有点复杂,如前面所属,服务器侧为了应对各种调整,引入了多种层次的抽象和不同实现。前面介绍了断点层次化管理机制,这部分信息对于理解serverside处理流程非常重要。 OK,假定读者朋友们已经理解了上述内容,现在我们整体介绍下serverside添加断点的处理流程。 ```bash rpc2/server.go:CreateBreakpoint func (s *RPCServer) CreateBreakpoint(arg CreateBreakpointIn, out *CreateBreakpointOut) error { \--> err := api.ValidBreakpointName(arg.Breakpoint.Name) \--> createdbp, err := s.debugger.CreateBreakpoint(&arg.Breakpoint, arg.LocExpr, arg.SubstitutePathRules, arg.Suspended) | \--> checking: if breakpoints with the same name as requestBp.Name created before | d.findBreakpointByName(requestedBp.Name) | \--> checking: if breakpoints with the same requestBp.ID created before | lbp := d.target.LogicalBreakpoints[requestedBp.ID] | \--> breakpoint config, initialized based on following order | | \--> case requestedBp.TraceReturn, | | setbp.PidAddrs = []proc.PidAddr{{Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr}} | | \--> case requestedBp.File != "", | | setbp.File = requestBp.File | | setbp.Line = requestBp.Line | | \--> requestedBp.FunctionName != "", | | setbp.FunctionName = requestedBp.FunctionName | | setbp.Line = requestedBp.Line | | \--> len(requestedBp.Addrs) > 0, | | setbp.PidAddrs = make([]proc.PidAddr, len(requestedBp.Addrs)) | | then, fill the setbp.PidAddrs with slice of PidAddr{pid,addr} | | \--> default, setbp.Addr = requestBp.Addr | \--> if locexpr != "", | \--> setbp.Expr = func(t *proc.Target) []uint64 {...} | \--> setbp.ExprString = locExpr | \--> create the logical breakpoint | | \--> `id`, allocate a logical breakpoint ID | | \--> lbp := &proc.LogicalBreakpoint{LogicalID: id, HitCount: make(map[int64]uint64)} | | \--> err = d.target.SetBreakpointEnabled(lbp, true) | | | \--> if lbp.enabled && !enabled, then | | | lbp.enabled = false | | | err = grp.disableBreakpoint(lbp) | | | \--> if !lbp.enabled && enabled, then | | | lbp.enabled = true | | | lbp.condSatisfiable = breakpointConditionSatisfiable(grp.LogicalBreakpoints, lbp) | | | err = grp.enableBreakpoint(lbp) | | \--> return d.convertBreakpoint(lbp) \--> out.Breakpoint = *createdbp ``` 简单总结下这里的处理流程: 1. 创建断点时如果指定了name,先检查名字是否符合要求(必须是unicode字符,并且不能为纯数字)。 不符合要求直接返回失败。 2. 开始创建断点,如果指定了name,检查下这个名字是否已经被其他逻辑断点使用了。 名字被使用则返回错误。 3. 如果指定了逻辑断点ID,则检查该ID是否已经被其他逻辑断点使用了。 ID被使用则返回错误,错误中说明了使用该ID的断点位置信息, `proc.BreakpointExistsError{File: lbp.File, Line: lbp.Line}`。 4. 根据请求参数中设置断点的方式,创建断点: - 如果requestBp.TraceReturn=true,说明是tracepoint请求中还需指定地址requestBp.Addr(函数调用返回地址) ``` setbp.PidAddrs = []proc.PidAddr{ {Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr} } ``` - 如果requestBp.File != "", 则使用requestBp.File:requestBp.Line来创建断点 ``` setbp.File = requestBp.File, setbp.Line = requestBp.Line ``` - 如果requestedBp.FunctionName != "",则使用requestBp.FunctionName:requestBp.Line来创建断点 ``` setbp.FunctionName = requestBp.FunctionName, setbp.Line = requestBp.Line ``` - 如果 len(requestedBp.Addrs) != 0,则在目标进程的这些地址处添加断点 ``` setbp.PidAddrs = []proc.PidAddr{.....} ``` - 其他情况,使用requestBp.Addr来设置断点 ``` setbp.PidAddr = []proc.PidAddr{ {Pid: d.target.Selected.Pid(), Addr: requestedBp.Addr} } ``` 5. 如果locExpr != "",则解析位置表达式得到LocationSpec,setbp.Expr实际上是个函数,执行后返回位置表达式查找到的地址列表 ``` setbp.Expr = func(t *proc.Target) []uint64 {...} setbp.ExprString = locExpr ``` 6. 更新逻辑断点的id,创建一个逻辑断点proc.LogicalBreakpoint{LogicalID: id, ...,Set: setbp, ...,File:...,Line:...,FunctionName:...,} 7. 设置逻辑断点对应的物理断点:err = d.target.SetBreakpointEnabled(lbp, true) 8. 将逻辑断点信息转换为api.Breakpoint信息返还给客户端展示 接下来看下 `d.target.SetBreakpointEnabled(lbp, true)`,设置逻辑断点关联的物理断点信息的流程。 ```bash err = d.target.SetBreakpointEnabled(lbp, true) \--> err = grp.enableBreakpoint(lbp) \--> for target in grp.targets, do: err := enableBreakpointOnTarget(target, lbp) | \--> addrs, err = FindFileLocation(t, lbp.Set.File, lbp.Set.Line), or | addrs, err = FindFunctionLocation(t, lbp.Set.FunctionName, lbp.Set.Line), or | filter the lbp.Set.PidAddrs if lbp.Set.PidAddrs[i].Pid == t.Pid(), or | runs lbp.Set.Expr() to find the address list | \--> foreach addr in addrs, do: | p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) | | \--> t.setBreakpointInternal(logicalID, addr, kind, 0, cond) | | | \--> newBreaklet := &Breaklet{LogicalID: logicalID, Kind: kind, Cond: cond} | | | | | | \--> if breakpoint existed at `addr`, then | | | check this newBreaklet can overlap: | | | 1) if no, return BreakpointExistsError{bp.File, bp.Line, bp.Addr}; | | | 2)if yes, bp.Breaklets = append(bp.Breaklets, newBreaklet), | | | 3) then `setLogicalBreakpoint(bp)`, and return | | | \--> else breakpoint not existed at `addr`, create a new breakpoint, so go on | | | | | | \--> f, l, fn := t.BinInfo().PCToLine(addr) | | | | | | \--> if it's watchtype: set hardware debug registers | | | ... | | | \--> newBreakpoint := &Breakpoint{funcName, watchType, hwidx, file, line, addr} | | | \--> newBreakpoint.Breaklets = append(newBreakpoint.Breaklets, newBreaklet) | | | \--> err := t.proc.WriteBreakpoint(newBreakpoint) | | | | \--> if bp.WatchType != 0, then | | | | for each thread in dbp.threads, do | | | | err := thread.writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) | | | | return nil | | | | \--> _, err := dbp.memthread.ReadMemory(bp.OriginalData, bp.Addr) | | | | \--> return dbp.writeSoftwareBreakpoint(dbp.memthread, bp.Addr) | | | | \--> _, err := thread.WriteMemory(addr, dbp.bi.Arch.BreakpointInstruction()) | | | | \--> t.dbp.execPtraceFunc(func() { written, err = sys.PtracePokeData(t.ID, uintptr(addr), data) }) | | | \--> newBreakpoint.Breaklets = append(newBreakpoint.Breaklets, newBreaklet) | | | \--> setLogicalBreakpoint(newBreakpoint) ``` 那么 `setLogicalBreakpoint(newBreakpoint)`又具体做了什么呢? ```go setLogicalBreakpoint(newBreakpoint) \--> if bp.WatchType != 0, then \--> foreach thead in dbp.threads, do err := thread.writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) return err \--> return dbp.writeSoftwareBreakpoint(dbp.memthread, bp.Addr) \--> _, err := thread.WriteMemory(addr, dbp.bi.Arch.BreakpointInstruction()) \--> t.dbp.execPtraceFunc(func() { written, err = sys.PtracePokeData(t.ID, uintptr(addr), data) }) ``` 是不是感觉有点混乱?是! 主要是明确这几点: - 这个逻辑断点对进程组grp中的所有进程都生效 `grp.enableBreakpoint(lbp) -> enableBreakpointOnTarget(target, lbp)`; - 这个逻辑断点位置,可能对应着多个机器指令地址,`FindFileLocation(...), or FindFunctionLocation, or filter from lbp.Set.PidAddrs, or runs lbp.Set.Expr() to find address` - 每个找到的机器指令地址处都需要添加物理断点 `p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) -> t.setBreakpointInternal(logicalID, addr, kind, 0, cond)` - 物理断点 #### 关键拆解 - **逻辑断点全局共享,统一管理**:所有断点都是逻辑断点,在 TargetGroup 级别统一管理,避免重复设置 ```go // 在 TargetGroup 中 LogicalBreakpoints map[int]*LogicalBreakpoint ``` 当在进程P1的线程T1上设置断点时,创建的是一个逻辑断点。这个逻辑断点会被自动应用到所有相关的进程和线程,这离不开下面的自动断点传播机制。 - **自动断点传播机制,调试便利**:新进程、新线程自动继承现有的断点 当新进程或线程加入调试组时,断点会自动传播: ```go func (grp *TargetGroup) addTarget(p ProcessInternal, pid int, currentThread Thread, path string, stopReason StopReason, cmdline string) (*Target, error) { ... t, err := grp.newTarget(p, pid, currentThread, path, cmdline) ... // 共享逻辑断点 t.Breakpoints().Logical = grp.LogicalBreakpoints // 自动为新目标启用所有现有的逻辑断点 for _, lbp := range grp.LogicalBreakpoints { if lbp.LogicalID < 0 { continue } // 在新目标上启用断点 err := enableBreakpointOnTarget(t, lbp) ... } ... } func enableBreakpointOnTarget(p *Target, lbp *LogicalBreakpoint) error { // 根据断点类型决定在哪些地址设置物理断点 switch { case lbp.Set.File != "": // 文件行断点:在所有匹配的地址设置 addrs, err = FindFileLocation(p, lbp.Set.File, lbp.Set.Line) case lbp.Set.FunctionName != "": // 函数断点:在函数入口设置 addrs, err = FindFunctionLocation(p, lbp.Set.FunctionName, lbp.Set.Line) case len(lbp.Set.PidAddrs) > 0: // 指定进程指定地址处添加断点:过滤出目标进程为p的逻辑断点进行设置 for _, pidAddr := range lbp.Set.PidAddrs { if pidAddr.Pid == p.Pid() { addrs = append(addrs, pidAddr.Addr) } } } // 在每个地址设置物理断点 for _, addr := range addrs { _, err = p.SetBreakpoint(lbp.LogicalID, addr, UserBreakpoint, nil) } } ``` - **断点状态同步,全局共享**:断点命中计数等信息在逻辑断点级别维护,所有进程、线程共享 ```go // 逻辑断点:用户概念上的断点 type LogicalBreakpoint struct { LogicalID int Set SetBreakpoint // 断点设置信息 enabled bool HitCount map[int64]uint64 // 命中计数 TotalHitCount uint64 // ... } ``` - **断点启用策略,控制灵活**:通过 follow-exec 和正则表达式控制断点传播范围 如果打开了followExec模式,并且followExecRegexp不空,此时就会检查子进程执行的cmdline是否匹配,如果匹配就会自动追踪并进行断点传播。 ```bash target follow-exec -on // 打开follow-exec模式 target follow-exec -on "myapp.*" // 打开follow-exec模式,但是只跟踪cmdline匹配myapp.*的子进程 target follow-exec -off // 关闭follow-exec模式 ``` 处理逻辑详见: ```go type TargetGroup struct { followExecEnabled bool // 是否启用 follow-exec followExecRegex *regexp.Regexp // 正则表达式过滤器 // ... } func (grp *TargetGroup) addTarget(p ProcessInternal, pid int, currentThread Thread, path string, stopReason StopReason, cmdline string) (*Target, error) { logger := logflags.LogDebuggerLogger() if len(grp.targets) > 0 { // 检查是否启用 follow-exec if !grp.followExecEnabled { logger.Debugf("Detaching from child target (follow-exec disabled) %d %q", pid, cmdline) return nil, nil // 不跟踪子进程 } // 检查正则表达式过滤 if grp.followExecRegex != nil && !grp.followExecRegex.MatchString(cmdline) { logger.Debugf("Detaching from child target (follow-exec regex not matched) %d %q", pid, cmdline) return nil, nil // 不跟踪不匹配的进程 } } // 新进程被添加到调试组,所有现有断点会自动应用 t.Breakpoints().Logical = grp.LogicalBreakpoints for _, lbp := range grp.LogicalBreakpoints { err := enableBreakpointOnTarget(t, lbp) // 在新进程中设置断点 } } ``` OK,接下来我们将在下一小节看下 `breakpoint` 命令在clientside、serverside分别是如何实现的。 ### 代码实现: `breakpoints` ### 代码实现: `clear` ### 代码实现: `clearall` ### 执行测试 ### 本节小结 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/26-debug_trace.md ================================================ ## trace trace,在指定位置设置tracepoint,本质上还是断点,命中后并打印相关位置信息,然后恢复执行; ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/27-debug_watch.md ================================================ ## watchpoint watch,监视对某个变量或者内存地址处的读写操作,是借助硬件断点对特定地址的数据读写、指令执行来实现的; 本文我们介绍下watchpoint的设计及实现,详细解释一下watchpoint的实现原理,以及它与普通断点breakpoint的区别。 ### 实现目标 `watch -r|-w|-rw` ```bash (tinydbg) help watch Set watchpoint. watch [-r|-w|-rw] -r stops when the memory location is read -w stops when the memory location is written -rw stops when the memory location is read or written The memory location is specified with the same expression language used by 'print', for example: watch v watch -w *(*int)(0x1400007c018) will watch the address of variable 'v' and writes to an int at addr '0x1400007c018'. Note that writes that do not change the value of the watched memory address might not be reported. See also: "help print". ``` ### 代码实现 #### 1. 硬件断点机制 Watchpoint是基于**硬件断点(Hardware Breakpoint)**实现的,它利用了CPU的调试寄存器(Debug Registers): ```go // 在 pkg/proc/amd64util/debugregs.go 中 type DebugRegisters struct { pAddrs [4]*uint64 // DR0-DR3: 存储断点地址 pDR6, pDR7 *uint64 // DR6: 状态寄存器, DR7: 控制寄存器 Dirty bool } ``` - **DR0-DR3**: 存储4个硬件断点的地址 - **DR6**: 状态寄存器,记录哪个断点被触发 - **DR7**: 控制寄存器,配置断点的类型(读/写/执行)和大小 #### 2. Watchpoint的设置过程 ```523:596:pkg/proc/breakpoints.go // SetWatchpoint sets a data breakpoint at addr and stores it in the // process wide break point table. func (t *Target) SetWatchpoint(logicalID int, scope *EvalScope, expr string, wtype WatchType, cond ast.Expr) (*Breakpoint, error) { // 1. 验证watchpoint类型(至少需要读或写) if (wtype&WatchWrite == 0) && (wtype&WatchRead == 0) { return nil, errors.New("at least one of read and write must be set for watchpoint") } // 2. 解析表达式并获取变量地址 n, err := parser.ParseExpr(expr) if err != nil { return nil, err } xv, err := scope.evalAST(n) if err != nil { return nil, err } // 3. 验证变量是否可以被监视 if xv.Addr == 0 || xv.Flags&VariableFakeAddress != 0 || xv.DwarfType == nil { return nil, fmt.Errorf("can not watch %q", expr) } // 4. 特殊处理接口类型 if xv.Kind == reflect.Interface { _, data, _ := xv.readInterface() xv = data expr = expr + " (interface data)" } // 5. 检查变量大小限制 sz := xv.DwarfType.Size() if sz <= 0 || sz > int64(t.BinInfo().Arch.PtrSize()) { return nil, fmt.Errorf("can not watch variable of type %s", xv.DwarfType.String()) } // 6. 检查栈变量特殊处理,栈上变量,不能 `watch -r`,因为栈会resize stackWatch := scope.g != nil && !scope.g.SystemStack && xv.Addr >= scope.g.stack.lo && xv.Addr < scope.g.stack.hi if stackWatch && wtype&WatchRead != 0 { return nil, errors.New("can not watch stack allocated variable for reads") } // 7. 创建硬件断点 bp, err := t.setBreakpointInternal(logicalID, xv.Addr, UserBreakpoint, wtype.withSize(uint8(sz)), cond) if err != nil { return bp, err } bp.WatchExpr = expr // 8. 如果是栈变量,设置额外的监视断点 if stackWatch { bp.watchStackOff = int64(bp.Addr) - int64(scope.g.stack.hi) err := t.setStackWatchBreakpoints(scope, bp) if err != nil { return bp, err } } return bp, nil } ``` #### 3. 硬件断点的写入和清除 ```180:220:pkg/proc/native/proc.go func (dbp *nativeProcess) WriteBreakpoint(bp *proc.Breakpoint) error { if bp.WatchType != 0 { // 硬件断点:为所有线程设置调试寄存器 for _, thread := range dbp.threads { err := thread.writeHardwareBreakpoint(bp.Addr, bp.WatchType, bp.HWBreakIndex) if err != nil { return err } } return nil } // 软件断点:替换内存中的指令 bp.OriginalData = make([]byte, dbp.bi.Arch.BreakpointSize()) _, err := dbp.memthread.ReadMemory(bp.OriginalData, bp.Addr) if err != nil { return err } return dbp.writeSoftwareBreakpoint(dbp.memthread, bp.Addr) } ``` ### Watchpoint vs Breakpoint #### 1. **实现机制不同** | 特性 | Watchpoint | Breakpoint | |------|------------|------------| | **实现方式** | 硬件断点(CPU调试寄存器) | 软件断点(指令替换) | | **触发条件** | 内存访问(读/写) | 指令执行 | | **数量限制** | 最多4个(x86-64) | 理论上无限制 | | **性能影响** | 很小 | 较大(需要指令替换) | #### 2. **触发时机不同** - **Watchpoint**: 当程序访问(读或写)特定内存地址时触发 - **Breakpoint**: 当程序执行到特定指令地址时触发 #### 3. **使用场景不同** - **Watchpoint**: - 监视变量值的变化 - 检测内存访问模式 - 调试数据竞争问题 - **Breakpoint**: - 在特定代码行停止执行 - 函数入口/出口断点 - 条件断点 #### 4. **技术实现细节** ##### Watchpoint的硬件支持: ```go // 在 pkg/proc/amd64util/debugregs.go 中 func (drs *DebugRegisters) SetBreakpoint(idx uint8, addr uint64, read, write bool, sz int) error { // 设置地址 *(drs.pAddrs[idx]) = addr // 配置类型(读/写)和大小 var lenrw uint64 if write { lenrw |= 0x1 } if read { lenrw |= 0x2 } // 设置大小(1, 2, 4, 8字节) switch sz { case 1: // 1字节 case 2: lenrw |= 0x1 << 2 case 4: lenrw |= 0x3 << 2 case 8: lenrw |= 0x2 << 2 } // 写入控制寄存器并启用 *(drs.pDR7) &^= (0xf << lenrwBitsOffset(idx)) *(drs.pDR7) |= lenrw << lenrwBitsOffset(idx) *(drs.pDR7) |= 1 << enableBitOffset(idx) return nil } ``` ##### 检测硬件断点触发: ```go func (drs *DebugRegisters) GetActiveBreakpoint() (ok bool, idx uint8) { for idx := uint8(0); idx <= 3; idx++ { enable := *(drs.pDR7) & (1 << enableBitOffset(idx)) if enable == 0 { continue } if *(drs.pDR6)&(1< funcs `` ### 实现目标 前面一节介绍了调试器后端ListFunctions的实现,这一小节介绍下在此基础上 `godbg> funcs ` 的实现。 ### 基础知识 前面我们介绍了debug session中前后端通信是大致怎样一个过程,也介绍了ListFunctions的实现,ok,那要实现调试会话命令 `godbg> funcs ` 就简单了。无非是通过JSON-RPC的client调用远程过程ListFunctions。 ### 代码实现 下面一起来这部分的关键代码逻辑。 #### 请求和响应参数类型 `ListFunctions` RPC调用接受两个参数: ```go type ListFunctionsIn struct { Filter string // 用于过滤函数名的正则表达式模式 FollowCalls int // 跟踪函数调用的深度(0表示不跟踪) } type ListFunctionsOut struct { Funcs []string // 匹配的函数名列表 } ``` #### 正则表达式过滤 函数名过滤使用正则表达式实现。当提供过滤模式时,它会被编译成正则表达式对象: 这允许用户使用以下模式搜索函数: - `main.*` - 所有以"main"开头的函数 - `.*Handler` - 所有以"Handler"结尾的函数 - `[A-Z].*` - 所有导出的函数 #### 函数调用遍历 这里的go函数调用路径,大致如下: ```bash // clientside 执行调试命令 tinydbg> funcs \--> funcsCmd.cmdFn() \--> funcs(s *Session, ctx callContext, args string) \--> t.printSortedStrings(t.client.ListFunctions(...)) \--> rpc2.(*RPCClient).ListFunctions(...) ``` 一起看下clientside如何实现的: ```go func (c *RPCClient) ListFunctions(filter string, TraceFollow int) ([]string, error) { funcs := new(ListFunctionsOut) err := c.call("ListFunctions", ListFunctionsIn{filter, TraceFollow}, funcs) return funcs.Funcs, err } ``` 下面再看下serverside是如何实现的: t.client.ListFunctions(...)` 对应着服务器端的ListFunctions处理 ```go // ListFunctions lists all functions in the process matching filter. func (s *RPCServer) ListFunctions(arg ListFunctionsIn, out *ListFunctionsOut) error { fns, err := s.debugger.Functions(arg.Filter, arg.FollowCalls) if err != nil { return err } out.Funcs = fns return nil } ``` 服务器端调用这个小哥(*RPCServer).ListFunctions(...),然后调用到debuggger.Functions。下面我们看看 `s.debugger.Functions(filter, followCalls)`: ```go // Functions returns a list of functions in the target process. func (d *Debugger) Functions(filter string, followCalls int) ([]string, error) { d.targetMutex.Lock() defer d.targetMutex.Unlock() regex, err := regexp.Compile(filter) if err != nil { return nil, fmt.Errorf("invalid filter argument: %s", err.Error()) } funcs := []string{} t := proc.ValidTargets{Group: d.target} for t.Next() { for _, f := range t.BinInfo().Functions { if regex.MatchString(f.Name) { if followCalls > 0 { newfuncs, err := traverse(t, &f, 1, followCalls) if err != nil { return nil, fmt.Errorf("traverse failed with error %w", err) } funcs = append(funcs, newfuncs...) } else { funcs = append(funcs, f.Name) } } } } // uniq = sort + compact sort.Strings(funcs) funcs = slices.Compact(funcs) return funcs, nil } ``` 上述代码是展示了如何为智能体增加多方智能,并不是不可能的。 ### 本节小结 本节介绍了调试器命令 `godbg> funcs ` 的实现。该命令通过JSON-RPC调用远程的ListFunctions过程,支持正则表达式过滤函数名,并可设置函数调用跟踪深度。实现展示了调试器前后端的关键代码处理逻辑。 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/34-debug_vars.md ================================================ 打印变量,以print name为例,这里首先需要检查所有的DIE,找到name对应的DIE,然后找到对应的Type DIE,然后再结合name变量值在内存中的地址,结合这里的Type DIE的描述信息来读取并理解内存中的数据。这样就完成了变量name数据的读取显示。 我们优先实现几个类型的变量的读取。 - string,当前已实现 - int,TODO - []int,TODO - struct{},TODO 我们还想添加一个gdb中的非常有用的调试命令ptype,比如ptype name,name其实是一个变量名,这个时候会打印出name这个变量的类型,如果name是string则显示stringheader的详细信息,而不只是显示string,这样对大家学习更友好一点。 当然也可以print name的时候显示为:string: "zhangjie",表示name是string类型,value是"zhangjie",而ptype name的时候则显示stringheader的信息。 TODO 任务优先级:高 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/35-debug_bt.md ================================================ 需要新增加一个调试命令backtrack|bt,这个命令将打印当前函数所处的调用栈信息。前面简单介绍过如何读取.[z]debug_frame信息并用来查看指定pc对应的FDE,以及当下的CFA计算计算规则等,要实现这个bt特性,只需要了解下go程序调用栈的组织方式解决。 我们习惯上称之为Call Convention,只要了解了go函数传递参数、返回值的方式,存储返回地址的方式,存储caller base frame pointer的方式,解决这个问题就非常简单。 TODO 任务优先级:高 ================================================ FILE: book/9-develop-sym-debugger/2-核心调试逻辑/README.md ================================================ ## 核心调试逻辑 接下来,我们将开发一个面向Go语言的符号级调试器,它除了具备前面介绍过的基础的指令级调试能力,也会支持源码级别的调试,同时也会根据Go的语言特性来提供一些更友好易用的调试能力,如支持协程级别(而非线程级别)的断点能力,支持切换到不同的线程、协程,支持函数调用,等等吧。 和前面介绍指令级调试时的内容组织类似,我们会先对调试器的整体框架进行介绍,比如如何实现前后端分离式架构,前后端RPC通信如何设计实现,UI层、Service层、符号层、目标层各自可以如何进行扩展。然后,我们介绍下调试器要支持的调试方式(attach、exec、debug、test、core、trace),以及调试会话中要支持的调试命令(breakpoint、tracepoint、continue、list、print、bt,等等)。最后设计实现小节,我们再介绍各个调试命令的实现细节。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/100-howto_integrate_starlark.md ================================================ ## 扩展阅读:starlark让你的程序更强大 starlark是一门配置语言,它是从Python语言中衍生出来的,但是比Python更简单、更安全。它最初是由Google开发的,用于Bazel构建系统。starlark保留了Python的基本语法和数据类型,但移除了一些危险的特性,比如循环引用、无限递归等。这使得starlark非常适合作为配置语言或者脚本语言嵌入到其他程序中。 starlark的主要特点包括: 1. 简单易学 - 采用Python风格的语法,对于熟悉Python的开发者来说几乎没有学习成本 2. 确定性 - 相同的输入总是产生相同的输出,没有随机性和副作用 3. 沙箱隔离 - 不能访问文件系统、网络等外部资源,保证安全性 4. 可扩展 - 可以方便地将宿主语言(如Go)的函数暴露给starlark使用 5. 快速执行 - 解释器性能优秀,适合嵌入式使用 这些特性使得starlark成为一个理想的嵌入式配置/脚本语言。通过将starlark集成到我们的Go程序中,我们可以让用户使用starlark脚本来扩展和自定义程序的功能,同时又能保证安全性和可控性。 比如在go-delve/delve调试器中,starlark被用来编写自动化调试脚本。用户可以使用starlark脚本来自动执行一系列调试命令,或者根据特定条件触发某些调试操作。这大大增强了调试器的灵活性和可编程性。 下面我们将通过一个简单的例子来演示如何在Go程序中集成starlark引擎,并实现Go函数与starlark函数的相互调用。 ### 集成starlark引擎到Go程序 首先我们来看一个简单的例子,演示如何将starlark引擎集成到Go程序中。这个例子实现了一个简单的REPL(Read-Eval-Print Loop)环境,允许用户输入starlark代码并立即执行: ```go package main import ( ... "go.starlark.net/starlark" "go.starlark.net/syntax" ) func main() { // Create a new Starlark thread thread := &starlark.Thread{ Name: "repl", Print: func(thread *starlark.Thread, msg string) { fmt.Println(msg) }, } // Create a new global environment globals := starlark.StringDict{} // Create a scanner for reading input scanner := bufio.NewScanner(os.Stdin) fmt.Println("Starlark REPL (type 'exit' to quit)") errExit := errors.New("exit") for { // Print prompt fmt.Print(">>> ") // Read input readline := func() ([]byte, error) { if !scanner.Scan() { return nil, io.EOF } line := strings.TrimSpace(scanner.Text()) if line == "exit" { return nil, errExit } if line == "" { return nil, nil } return []byte(line + "\n"), nil } // Execute the input if err := rep(readline, thread, globals); err != nil { if err == io.EOF { break } if err == errExit { os.Exit(0) } fmt.Fprintf(os.Stderr, "Error: %v\n", err) } } } // rep reads, evaluates, and prints one item. // // It returns an error (possibly readline.ErrInterrupt) // only if readline failed. Starlark errors are printed. func rep(readline func() ([]byte, error), thread *starlark.Thread, globals starlark.StringDict) error { eof := false f, err := syntax.ParseCompoundStmt("", readline) if err != nil { if eof { return io.EOF } printError(err) return nil } if expr := soleExpr(f); expr != nil { //TODO: check for 'exit' // eval v, err := evalExprOptions(nil, thread, expr, globals) if err != nil { printError(err) return nil } // print if v != starlark.None { fmt.Println(v) } } else { // compile prog, err := starlark.FileProgram(f, globals.Has) if err != nil { printError(err) return nil } // execute (but do not freeze) res, err := prog.Init(thread, globals) if err != nil { printError(err) } // The global names from the previous call become // the predeclared names of this call. // If execution failed, some globals may be undefined. for k, v := range res { globals[k] = v } } return nil } var defaultSyntaxFileOpts = &syntax.FileOptions{ Set: true, While: true, TopLevelControl: true, GlobalReassign: true, Recursion: true, } // evalExprOptions is a wrapper around starlark.EvalExprOptions. // If no options are provided, it uses default options. func evalExprOptions(opts *syntax.FileOptions, thread *starlark.Thread, expr syntax.Expr, globals starlark.StringDict) (starlark.Value, error) { if opts == nil { opts = defaultSyntaxFileOpts } return starlark.EvalExprOptions(opts, thread, expr, globals) } func soleExpr(f *syntax.File) syntax.Expr { if len(f.Stmts) == 1 { if stmt, ok := f.Stmts[0].(*syntax.ExprStmt); ok { return stmt.X } } return nil } // printError prints the error to stderr, // or its backtrace if it is a Starlark evaluation error. func printError(err error) { if evalErr, ok := err.(*starlark.EvalError); ok { fmt.Fprintln(os.Stderr, evalErr.Backtrace()) } else { fmt.Fprintln(os.Stderr, err) } } ``` ### starlark直接调用Go函数 在这个例子中,我们将演示如何让starlark脚本调用Go函数。主要思路是: 1. 定义一个Go函数映射表(GoFuncMap)来注册可供starlark调用的Go函数 2. 实现一个胶水函数(callGoFunc)作为starlark和Go函数之间的桥梁 3. 将胶水函数注册到starlark全局环境中,这样starlark代码就可以通过它来调用Go函数 下面是一个简单的示例,展示如何让starlark调用一个Go的加法函数: ```go package main import ( ... "go.starlark.net/starlark" "go.starlark.net/syntax" ) // GoFuncMap stores registered Go functions var GoFuncMap = map[string]interface{}{ "Add": Add, } func Add(a, b int) int { fmt.Println("Hey! I'm a Go function!") return a + b } // callGoFunc is a Starlark function that calls registered Go functions func callGoFunc(thread *starlark.Thread, fn *starlark.Builtin, args starlark.Tuple, kwargs []starlark.Tuple) (starlark.Value, error) { if len(args) < 1 { return nil, fmt.Errorf("call_gofunc requires at least one argument (function name)") } funcName, ok := args[0].(starlark.String) if !ok { return nil, fmt.Errorf("first argument must be a string (function name)") } goFunc, ok := GoFuncMap[string(funcName)] if !ok { return nil, fmt.Errorf("function %s not found", funcName) } // Convert Starlark arguments to Go values goArgs := make([]interface{}, len(args)-1) for i, arg := range args[1:] { switch v := arg.(type) { case starlark.Int: if v, ok := v.Int64(); ok { goArgs[i] = int(v) } else { return nil, fmt.Errorf("integer too large") } case starlark.Float: goArgs[i] = float64(v) case starlark.String: goArgs[i] = string(v) case starlark.Bool: goArgs[i] = bool(v) default: return nil, fmt.Errorf("unsupported argument type: %T", arg) } } // Call the Go function switch f := goFunc.(type) { case func(int, int) int: if len(goArgs) != 2 { return nil, fmt.Errorf("Add function requires exactly 2 arguments") } a, ok1 := goArgs[0].(int) b, ok2 := goArgs[1].(int) if !ok1 || !ok2 { return nil, fmt.Errorf("Add function requires integer arguments") } result := f(a, b) return starlark.MakeInt(result), nil default: return nil, fmt.Errorf("unsupported function type: %T", goFunc) } } func main() { go func() { // Create a new Starlark thread thread := &starlark.Thread{ Name: "repl", Print: func(thread *starlark.Thread, msg string) { fmt.Println(msg) }, } // Create a new global environment with call_gofunc globals := starlark.StringDict{ "call_gofunc": starlark.NewBuiltin("call_gofunc", callGoFunc), } // Create a scanner for reading input scanner := bufio.NewScanner(os.Stdin) fmt.Println("Starlark REPL (type 'exit' to quit)") fmt.Println("Example1: starlark exprs and stmts") fmt.Println("Example2: call_gofunc('Add', 1, 2)") errExit := errors.New("exit") for { // Print prompt fmt.Print(">>> ") // Read input readline := func() ([]byte, error) { ... } // Execute the input if err := rep(readline, thread, globals); err != nil { ... } } }() select {} } ``` ### 调试器集成 starlark go-delve/delve 中集成了starlark,并使用本文体积的方法来支持了对某些调试器内部函数的调用,比如: ```go //go:generate go run ../../../_scripts/gen-starlark-bindings.go go ./starlark_mapping.go const ( dlvCommandBuiltinName = "dlv_command" readFileBuiltinName = "read_file" writeFileBuiltinName = "write_file" commandPrefix = "command_" dlvContextName = "dlv_context" curScopeBuiltinName = "cur_scope" defaultLoadConfigBuiltinName = "default_load_config" helpBuiltinName = "help" ) ``` 比如有下面的go源程序,我们使用 go-delve/delve 来进行自动化调试: file: main.go (这里保留行号信息,方便与starlark脚本对应) ```go 1 package main 2 3 import ( 4 "fmt" 5 "time" 6 ) 7 8 type Person struct { 9 Name string 10 Age int 11 } 12 13 func main() { 14 people := []Person{ 15 {Name: "Alice", Age: 25}, 16 {Name: "Bob", Age: 30}, 17 {Name: "Charlie", Age: 35}, 18 } 19 20 for i, p := range people { 21 fmt.Printf("Processing person %d: %s\n", i, p.Name) 22 time.Sleep(time.Second) // 添加一些延迟以便于调试 23 processPerson(p) 24 } 25 } 26 27 func processPerson(p Person) { 28 fmt.Printf("Name: %s, Age: %d\n", p.Name, p.Age) 29 } ``` starlark自动化调试脚本: file: debug.star ``` # 定义一个函数来打印当前作用域的信息 def print_scope(): scope = cur_scope() print("Current scope:", scope) dlv_command("locals") # 定义一个函数来设置断点并执行调试命令 def debug_person(): # 打印当前作用域 print_scope() # 打印变量 p 的值 dlv_command("print p") # 单步执行 dlv_command("next") # 再次打印作用域 print_scope() # 定义一个函数来保存调试信息到文件 def save_debug_info(): # 获取当前作用域 scope = cur_scope() # 将调试信息写入文件 debug_info = "Debug session at " + str(time.time()) + "\n" debug_info += "Current scope: " + str(scope) + "\n" # 保存到文件 write_file("debug_info.txt", debug_info) # 主函数 def main(): print("Starting debug session...") # 设置断点 dlv_command("break main.main") dlv_command("break main.processPerson") # 继续执行到main.main dlv_command("continue") # 继续执行到main.processPerson dlv_command("continue") # 执行调试操作 debug_person() # 保存调试信息 save_debug_info() print("Debug session completed.") # 直接调用 main 函数 (source命令会自动调用定义的 `main` 函数) #main() ``` 运行调试器 `dlv debug main.go`,调试会话就绪后运行 `source debug.star` 即可。 ```bash $ tinydbg debug main.go Type 'help' for list of commands. (dlv) source debug.star Starting debug session... Breakpoint 1 set at 0x49d0f6 for main.main() ./main.go:13 Breakpoint 2 set at 0x49d40e for main.processPerson() ./main.go:27 > [Breakpoint 1] main.main() ./main.go:13 (hits goroutine(1):1 total:1) (PC: 0x49d0f6) 8: type Person struct { 9: Name string 10: Age int 11: } 12: => 13: func main() { 14: people := []Person{ 15: {Name: "Alice", Age: 25}, 16: {Name: "Bob", Age: 30}, 17: {Name: "Charlie", Age: 35}, 18: } Processing person 0: Alice > [Breakpoint 2] main.processPerson() ./main.go:27 (hits goroutine(1):1 total:1) (PC: 0x49d40e) 22: time.Sleep(time.Second) // 添加一些延迟以便于调试 23: processPerson(p) 24: } 25: } 26: => 27: func processPerson(p Person) { 28: fmt.Printf("Name: %s, Age: %d\n", p.Name, p.Age) 29: } Current scope: api.EvalScope{GoroutineID:-1, Frame:0, DeferredCall:0} (no locals) main.Person {Name: "Alice", Age: 25} > main.processPerson() ./main.go:28 (PC: 0x49d42a) 23: processPerson(p) 24: } 25: } 26: 27: func processPerson(p Person) { => 28: fmt.Printf("Name: %s, Age: %d\n", p.Name, p.Age) 29: } Current scope: api.EvalScope{GoroutineID:-1, Frame:0, DeferredCall:0} (no locals) Debug session completed. ``` tinydbg暂时保留了go-delve/delve中的starlark实现,pkg/terminal/starlark.go + pkg/terminal/starlark_test.go 一共300行代码,starbind/ 下有近3000行代码,不过这部分代码是通过脚本自动生成的。由于这部分代码相对来说比较独立,不像ebpf-based tracing那样影响到很多地方,所以我们暂时保留这部分代码。上述测试用的源码、star脚本,您可以在路径 tinydbg/examples/starlark_demo 找到。 ### 本节小结 我在学习bazelbuild时了解到starlark这门语言,在学习go-delve/delve时进一步了解了它。如果我们正在编写一个工具或者分析型工具,希望通过暴漏我们的底层能力,以让用户自由发挥他们的创造性用途,比如类似go-delve/delve希望用户可以按需执行自动化调试,我们其实可以将starlark解释器引擎集成到我们的程序中,然后通过一点胶水代码打通starlark与我们的程序,使得starlark解释器调用starlark函数来执行我们程序中定义的函数。这无疑会释放我们程序的底层能力,允许使用者在底层能力开放程度受控的情况下进一步去发挥、去挖掘。 本文演示了如何轻松starklark集成到您的Go程序中,starlark的更多用法请参考 [bazelbuild/starlark](https://github.com/bazelbuild/starlark)。 本文还介绍了调试器与starlark集成以及使用示例,有自动化测试诉求,或者希望分享你的调试会话的时候,可以通过这种方式来实现。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/101-howto_tracing_via_ebpf.md ================================================ ## 扩展阅读:breakpoint-based vs. eBPF-based tracing 在程序调试和性能分析中,跟踪(tracing)是一项非常重要的技术。目前主要有两种实现方案:基于断点(breakpoint-based)和基于eBPF(eBPF-based)。让我们来详细了解这两种方案的特点。 ### 两种跟踪方案对比 #### Breakpoint-based Tracing 断点跟踪是一种传统的跟踪方法,主要特点: - 实现原理:在目标函数入口处设置软件断点(int3指令),当程序执行到断点处时触发trap异常,由调试器捕获并处理 - 优点: - 实现简单,无需内核支持 - 可以获取完整的上下文信息(寄存器、调用栈等) - 支持任意用户态程序 - 缺点: - 性能开销大,每次断点都会导致进程暂停 - 不支持内核态函数跟踪 - 对程序运行有侵入性 #### eBPF-based Tracing eBPF跟踪是一种新兴的跟踪技术,主要特点: - 实现原理:利用内核eBPF机制,在内核中注入跟踪程序,直接在内核态完成数据收集 - 优点: - 性能开销小,无需进程暂停 - 可以跟踪内核态和用户态函数 - 对程序运行几乎无侵入 - 缺点: - 需要较新的内核版本支持 - 实现相对复杂 - 受限于eBPF的安全限制 因为考虑到性能影响,使用eBPF-based tracing打印函数参数时,一般也只会获取函数的直接参数,而不会对函数参数中涉及到的指针进一步解引用,因为这涉及到ptrace相关的内存读取操作,肯定要在内存地址有效的情况下去读,最可靠的做法就是像调试器那样,要求目标程序处于TRACED、Stopped状态,因为内存的堆、栈是动态变化的。但是这样做目标程序的性能是会受明显影响的。 see also the discussion: - [go-delve/delve/issues/3586: Can dlv trace print the value of the arguments passed to a function? ](https://github.com/go-delve/delve/issues/3586#issuecomment-2911771133) ### eBPF跟踪实现方式 eBPF跟踪的基本实现步骤如下: 1. 编写eBPF程序 - 定义要跟踪的事件(kprobe/uprobe) - 编写事件处理逻辑 - 定义数据存储结构(map) 2. 加载eBPF程序 - 编译eBPF程序 - 通过bpf系统调用加载到内核 - 将程序attach到指定的跟踪点 3. 数据收集与处理 - eBPF程序在内核中执行,收集数据 - 通过map与用户态程序共享数据 - 用户态程序读取并处理数据 4. 结果展示 - 实时显示跟踪数据 - 统计分析 - 可视化展示 通过eBPF跟踪,我们可以以极低的开销实现强大的跟踪功能,这使其成为现代性能分析和监控工具的首选技术。 ### go程序tracing案例 #### 面临的挑战 由于go程序的特殊性,GMP调度,每个M可能会调用多个G,如果M先执行G1命中某个函数fn入口,然后切出继续执行G2也命中函数fn入口并顺利执行结束命中fn出口。此时从M视角看到的uprobe命中顺序是:fn的入口->fn的入口->fn的出口,但是命中fn的出口究竟是G1命中的呢,还是G2命中的呢? 这就是一个问题,虽然基于eBPF的tracing工具已经有很多,但是他们更多是面向一些C\C++等的基于线程编程模式的语言,它们并不理解Go的运行时调度,所以使用这些工具例如bpftrace、utrace来跟踪Go程序时就会出现统计混乱。 正确的解法就是,首先要理解Go Runtime的GMP调度,然后从当前线程的局部存储中取出 `m.tls.g.goid`,使用goid作为跟踪的对象,上述场景就可以被细化为: - goroutine-1(goid1)的事件序列:命中fn的入口 - goroutine-2(goid2)的事件序列:命中fn的入口->命中fn的出口 这样打印tracing信息时就可以从goroutine的维度来打印,而不是从线程的视角来打印。 #### 已有的案例 目前成功实现了Go程序eBPF-based tracing的工具目前主要由: - github.com/go-delve/delve,dlv trace - https://github.com/jschwinger233/gofuncgraph - github.com/hitzhangjie/go-ftrace 其中go-ftrace是我从gofuncgraph fork过来学习、修改、优化后的,并在此基础上编写了相关的examples,还写了几篇文章进行详细的介绍。由于篇幅原因,tinydbg中并没有保留go-delve/delve中的ebpf-based tracing实现,如果您感兴趣可以参考下面两篇文章,然后再去学习源码。 1. [观测Go函数调用:go-ftrace](https://www.hitzhangjie.pro/blog/2023-09-25-%E8%A7%82%E6%B5%8Bgo%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8go-ftrace/) 2. [观测Go函数调用:go-ftrace 设计实现](https://www.hitzhangjie.pro/blog/2023-12-12-%E8%A7%82%E6%B5%8Bgo%E5%87%BD%E6%95%B0%E8%B0%83%E7%94%A8go-ftrace%E8%AE%BE%E8%AE%A1%E5%AE%9E%E7%8E%B0/) ### 本节小结 本文介绍了如何使用eBPF技术来实现程序跟踪,详细讲解了eBPF跟踪的基本流程,包括编写eBPF程序、加载到内核、数据收集处理以及结果展示等关键步骤。特别指出了在跟踪Go程序时面临的特殊挑战 - 由于Go的GMP调度模型,传统的基于线程的跟踪方案并不适用。文章分析了这一问题的本质,并介绍了正确的解决方案:通过获取goroutine ID来实现准确的函数调用跟踪。同时也介绍了几个成功实现Go程序eBPF跟踪的开源工具,为读者提供了进一步学习和实践的参考。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/102-howto_syntax_highlight.md ================================================ ## 语法高亮 软件开发过程中对源代码进行语法高亮是非常有必要的,通过这种方式可以将程序中不同的要素进行有效地区分,如关键字、保留字、标识符、括号匹配、注释、字符串等等。开发人员使用的IDE一般都支持语法高亮,在vscode中通过gopls机制可以识别每个token所属的类别以及配合颜色主题便可以实现语法高亮,像vim、sublime等的编辑器也可以通过插件对不同编程语言的源代码进行语法高亮支持。 在我们使用调试器进行调试时,也经常需要查看源码,比如:1)使用list命令主动查看源码,`list main.main` or `list main.go:20` 以方便查看查看源码,或进一步确定要添加下个断点的位置;2)在逐语句 `next` 、逐指令知识 `step` 时,我们还希望展示当前已经执行到的源码位置、指令位置;3)在跟踪调用栈 `bt` 时,希望展示当前的函数调用栈 …… 这些时候如果能够对特定的源码位置的函数调用、语句、表达式、操作数、运算符、变量名、类型名等进行语法高亮显示,无疑会让可读性更好一点。 这个小节我们就来介绍下如何做到这点。 ### 如何实现 要实现语法高亮,需要做哪些工作呢?如果学习过编译原理,其实应该很容易想到,我们只需要实现一个词法分析器能够提取程序中的token序列,并通过语法分析器进行分析识别这些token具体为何物、它们之间具体是什么联系,是构成一个函数,还是构成一个表达式,还是简单到定义了一个变量、一个分支控制语句,等等。只要识别出来了,将这些不同的程序构造进行高亮显示自然不再困难。 ### 动手实践 我们就以go语言为例,来具体讨论下如何对源码进行高亮显示。自然我们不希望重新实现一遍词法分析器、语法分析器之类的琐碎工作,我们也没有精力去重新实现一遍这类工作。尽管flex、yacc可以帮助我们简化这类工作,但是go标准库其实已经提供了package ast来帮助我们做一些语法分析相关的工作。本文我们就基于package ast来演示下如何对go源码进行语法高亮。 设计一个package colorize来提供一个colorize.Print(...)方法,来将指定的源码文件进行高亮展示,并且允许指定源文件的行号范围、io.Writer、高亮颜色风格。只用编写如下几个源文件即可: - line_writer.go,负责按行输出,输出的时候允许指定token、高亮颜色风格,token包含了起始位置信息,所以配合颜色,即可完成对特定关键字、标识符、注释等不同程序构造的高亮显示; - colorize.go,负责读取源文件并对其进行AST分析,将其中我们要高亮的一些程序构造提取出来,如关键字package、var、func等作为token提取出来,并构造一个colorTok(包含了token本身位置信息、属于哪一类别,这里的类别决定了最终的颜色风格); - style.go,即高亮显示风格,不同类别对应着不同的终端颜色; 下面就是具体的源码实现了,其实这里的源码源自go-delve/delve,我在编写debugger101相关的demo时发现了go-delve/delve中存在的bug,并对其进行了修复,这里也算是简单记录分享一下吧。同学们真正有机会去尝试这个的也不多。 #### file: colorize.go ```go // Package colorize use AST analysis to analyze the source and colorize the different kinds // of literals, like keywords, imported packages, etc. // // If you want to highlight source parts, for example, the identifiers. // - firstly, colorTok must be generated by `emit(token.IDENT, n.Pos(), n.End())` in colorize.go // - secondly, we should map the token.IDENT to some style in style.go // - thirdly, we should define the color escape in terminal.go package colorize import ( "go/ast" "go/parser" "go/token" "io" "io/ioutil" "path/filepath" "reflect" "sort" ) // Print prints to out a syntax highlighted version of the text read from // path, between lines startLine and endLine. func Print(out io.Writer, path string, startLine, endLine, arrowLine int, colorEscapes map[Style]string) error { buf, err := ioutil.ReadFile(path) if err != nil { return err } w := &lineWriter{w: out, lineRange: [2]int{startLine, endLine}, arrowLine: arrowLine, colorEscapes: colorEscapes} var fset token.FileSet f, err := parser.ParseFile(&fset, path, buf, parser.ParseComments) if err != nil { w.Write(NormalStyle, buf, true) return nil } var base int fset.Iterate(func(file *token.File) bool { base = file.Base() return false }) type colorTok struct { tok token.Token // the token type or ILLEGAL for keywords start, end int // start and end positions of the token } toks := []colorTok{} emit := func(tok token.Token, start, end token.Pos) { if _, ok := tokenToStyle[tok]; !ok { return } start -= token.Pos(base) if end == token.NoPos { // end == token.NoPos it's a keyword and we have to find where it ends by looking at the file for end = start; end < token.Pos(len(buf)); end++ { if buf[end] < 'a' || buf[end] > 'z' { break } } } else { end -= token.Pos(base) } if start < 0 || start >= end || end > token.Pos(len(buf)) { // invalid token? return } toks = append(toks, colorTok{tok, int(start), int(end)}) } for _, cgrp := range f.Comments { for _, cmnt := range cgrp.List { emit(token.COMMENT, cmnt.Pos(), cmnt.End()) } } ast.Inspect(f, func(n ast.Node) bool { if n == nil { return true } switch n := n.(type) { case *ast.File: emit(token.PACKAGE, f.Package, token.NoPos) return true case *ast.BasicLit: emit(n.Kind, n.Pos(), n.End()) return true case *ast.Ident: // TODO(aarzilli): builtin functions? basic types? return true case *ast.IfStmt: emit(token.IF, n.If, token.NoPos) if n.Else != nil { for elsepos := int(n.Body.End()) - base; elsepos < len(buf)-4; elsepos++ { if string(buf[elsepos:][:4]) == "else" { emit(token.ELSE, token.Pos(elsepos+base), token.Pos(elsepos+base+4)) break } } } return true } nval := reflect.ValueOf(n) if nval.Kind() != reflect.Ptr { return true } nval = nval.Elem() if nval.Kind() != reflect.Struct { return true } tokposval := nval.FieldByName("TokPos") tokval := nval.FieldByName("Tok") if tokposval != (reflect.Value{}) && tokval != (reflect.Value{}) { emit(tokval.Interface().(token.Token), tokposval.Interface().(token.Pos), token.NoPos) } for _, kwname := range []string{"Case", "Begin", "Defer", "Package", "For", "Func", "Go", "Interface", "Map", "Return", "Select", "Struct", "Switch"} { kwposval := nval.FieldByName(kwname) if kwposval != (reflect.Value{}) { kwpos, ok := kwposval.Interface().(token.Pos) if ok && kwpos != token.NoPos { emit(token.ILLEGAL, kwpos, token.NoPos) } } } return true }) sort.Slice(toks, func(i, j int) bool { return toks[i].start < toks[j].start }) flush := func(start, end int, style Style) { if start < end { w.Write(style, buf[start:end], end == len(buf)) } } cur := 0 for _, tok := range toks { flush(cur, tok.start, NormalStyle) flush(tok.start, tok.end, tokenToStyle[tok.tok]) cur = tok.end } if cur != len(buf) { flush(cur, len(buf), NormalStyle) } return nil } ``` #### file: style.go ```go package colorize import "go/token" // Style describes the style of a chunk of text. type Style uint8 const ( NormalStyle Style = iota KeywordStyle StringStyle NumberStyle CommentStyle LineNoStyle ArrowStyle ) var tokenToStyle = map[token.Token]Style{ token.ILLEGAL: KeywordStyle, token.COMMENT: CommentStyle, token.INT: NumberStyle, token.FLOAT: NumberStyle, token.IMAG: NumberStyle, token.CHAR: StringStyle, token.STRING: StringStyle, token.BREAK: KeywordStyle, token.CASE: KeywordStyle, token.CHAN: KeywordStyle, token.CONST: KeywordStyle, token.CONTINUE: KeywordStyle, token.DEFAULT: KeywordStyle, token.DEFER: KeywordStyle, token.ELSE: KeywordStyle, token.FALLTHROUGH: KeywordStyle, token.FOR: KeywordStyle, token.FUNC: KeywordStyle, token.GO: KeywordStyle, token.GOTO: KeywordStyle, token.IF: KeywordStyle, token.IMPORT: KeywordStyle, token.INTERFACE: KeywordStyle, token.MAP: KeywordStyle, token.PACKAGE: KeywordStyle, token.RANGE: KeywordStyle, token.RETURN: KeywordStyle, token.SELECT: KeywordStyle, token.STRUCT: KeywordStyle, token.SWITCH: KeywordStyle, token.TYPE: KeywordStyle, token.VAR: KeywordStyle, } ``` #### file: line_writer.go ```go package colorize import ( "fmt" "io" ) type lineWriter struct { w io.Writer lineRange [2]int arrowLine int curStyle Style started bool lineno int colorEscapes map[Style]string } func (w *lineWriter) style(style Style) { if w.colorEscapes == nil { return } esc := w.colorEscapes[style] if esc == "" { esc = w.colorEscapes[NormalStyle] } fmt.Fprintf(w.w, "%s", esc) } func (w *lineWriter) inrange() bool { lno := w.lineno if !w.started { lno = w.lineno + 1 } return lno >= w.lineRange[0] && lno < w.lineRange[1] } func (w *lineWriter) nl() { w.lineno++ if !w.inrange() || !w.started { return } w.style(ArrowStyle) if w.lineno == w.arrowLine { fmt.Fprintf(w.w, "=>") } else { fmt.Fprintf(w.w, " ") } w.style(LineNoStyle) fmt.Fprintf(w.w, "%4d:\t", w.lineno) w.style(w.curStyle) } func (w *lineWriter) writeInternal(style Style, data []byte) { if !w.inrange() { return } if !w.started { w.started = true w.curStyle = style w.nl() } else if w.curStyle != style { w.curStyle = style w.style(w.curStyle) } w.w.Write(data) } func (w *lineWriter) Write(style Style, data []byte, last bool) { cur := 0 for i := range data { if data[i] == '\n' { if last && i == len(data)-1 { w.writeInternal(style, data[cur:i]) if w.curStyle != NormalStyle { w.style(NormalStyle) } if w.inrange() { w.w.Write([]byte{'\n'}) } last = false } else { w.writeInternal(style, data[cur:i+1]) w.nl() } cur = i + 1 } } if cur < len(data) { w.writeInternal(style, data[cur:]) } if last { if w.curStyle != NormalStyle { w.style(NormalStyle) } if w.inrange() { w.w.Write([]byte{'\n'}) } } } ``` ### 运行测试 下面是测试文件,我们定义了一个表示源码内容的字符串,并通过gomonkey mock掉了ioutil.ReadFile(...)的操作让其返回定义的源码字符串,然后执行colorize.Print(...)对其进行高亮显示。 file: colorize_test.go ```go package colorize_test import ( "bytes" "fmt" "io/ioutil" "reflect" "testing" "github.com/agiledragon/gomonkey/v2" "github.com/hitzhangjie/dlv/pkg/terminal/colorize" ) var src = `package main // Vehicle defines the vehicle behavior type Vehicle interface{ // Run vehicle can run in a speed Run() } // BMWS1000RR defines the motocycle bmw s1000rr type BMWS1000RR struct { } // Run bwm s1000rr run func (a *BMWS1000RR) Run() { println("I can run at 300km/h") } func main() { var vehicle = &BMWS1000RR{} vehicle.Run() } ` const terminalHighlightEscapeCode string = "\033[%2dm" const ( ansiBlack = 30 ansiRed = 31 ansiGreen = 32 ansiYellow = 33 ansiBlue = 34 ansiMagenta = 35 ansiCyan = 36 ansiWhite = 37 ansiBrBlack = 90 ansiBrRed = 91 ansiBrGreen = 92 ansiBrYellow = 93 ansiBrBlue = 94 ansiBrMagenta = 95 ansiBrCyan = 96 ansiBrWhite = 97 ) func colorizeCode(code int) string { return fmt.Sprintf(terminalHighlightEscapeCode, code) } var colors = map[colorize.Style]string{ colorize.KeywordStyle: colorizeCode(ansiYellow), colorize.ArrowStyle: colorizeCode(ansiBlue), colorize.CommentStyle: colorizeCode(ansiGreen), colorize.LineNoStyle: colorizeCode(ansiBrWhite), colorize.NormalStyle: colorizeCode(ansiBrWhite), colorize.NumberStyle: colorizeCode(ansiBrCyan), colorize.StringStyle: colorizeCode(ansiBrBlue), } func TestPrint(t *testing.T) { p := gomonkey.ApplyFunc(ioutil.ReadFile, func(name string) ([]byte, error) { return []byte(src), nil }) defer p.Reset() buf := &bytes.Buffer{} colorize.Print(buf, "main.go", bytes.NewBufferString(src), 1, 30, 10, colors) colorize.Print(os.Stdout, "main.go", bytes.NewBufferString(src), 1, 30, 10, colors) } ``` 现在运行这个测试用例 `go test -run TestPrint`,程序运行结果如下: highlight 我们看到程序中的部分程序元素被高亮显示了,当然我们只识别了简单的一小部分,关键字、字符串、注释,实际IDE中会分析的更加的细致,大家在使用IDE的时候应该也都有这方面的体会。 ### 本节小结 本文简单总结了如何基于go ast对源代码进行语法分析并进行高亮显示,希望读者能了解到这里的知识点,并能认识到编译原理的相关知识真的是可以用来做些有价值、有意思的东西的。再比如,我们实现一些linters对源码进行检查(如golangci-linter),作者之前还写过一篇文章是讲述如何对go程序进行可视化,有些IDE还支持自动生成classgram、callgraph等等,这些也是对go ast的另一种应用。 新的一年与大家共勉,做有追求的工程师,知其然知其所以然 :) ### 参考文献 1. [如何实现源代码语法高亮](https://www.hitzhangjie.pro/blog/2022-02-09-%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E6%BA%90%E4%BB%A3%E7%A0%81%E8%AF%AD%E6%B3%95%E9%AB%98%E4%BA%AE/) 2. [语法高亮实现pkg/colorize](https://github.com/go-delve/delve/tree/master/pkg/terminal/colorize) ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/103-howto_paging_output.md ================================================ ## 分页输出的设计与实现 ### 功能概述 分页输出(Paging Output)是调试器中的一个重要功能,它能够智能地处理大量输出内容,通过分页器(如 less、more 等)来展示输出,提升用户体验。这个功能在查看大量调试信息、堆栈跟踪或变量内容时特别有用。 ### 核心设计 分页输出的核心是 `pagingWriter` 结构体,它实现了 `io.Writer` 接口,可以动态决定是否使用分页器来展示输出。主要设计特点包括: 1. 支持多种输出模式: - 直接输出到终端 - 通过分页器输出 - 输出到文件 2. 智能判断: - 根据输出内容长度决定是否使用分页 - 考虑终端窗口大小 - 支持用户配置 ### 关键实现 ```go type pagingWriter struct { mode pagingWriterMode // 输出模式 w io.Writer // 基础输出流 buf []byte // 输出缓冲区 cmd *exec.Cmd // 分页器命令 cmdStdin io.WriteCloser // 分页器输入流 pager string // 分页器程序名,如环境变量设置PAGER=less or PAGER=more lastnl bool // 上次输出是否以换行结束 cancel func() // 取消函数 lines, columns int // 终端窗口大小 } ``` #### 输出流程 1. 初始化阶段: - 检测终端大小 - 确定输出模式 - 准备分页器(如果需要) 2. 写入阶段: - 缓冲输出内容 - 根据内容长度和终端大小决定是否启用分页 - 将内容写入到目标(终端/分页器/文件) 3. 清理阶段: - 关闭分页器 - 清理缓冲区 - 重置状态 ### 流程图 ```mermaid sequenceDiagram participant App participant PagingWriter participant Terminal participant Pager participant File App->>PagingWriter: 写入内容 PagingWriter->>PagingWriter: 缓冲内容 alt 内容较短 PagingWriter->>Terminal: 直接输出 else 内容较长 PagingWriter->>Pager: 启动分页器 PagingWriter->>Pager: 写入内容 Pager->>Terminal: 分页显示 else 输出到文件 PagingWriter->>File: 直接写入 end PagingWriter->>PagingWriter: 清理资源 ``` ### 使用场景 1. 调试会话记录: - 使用 transcript 命令时,可以选择是否启用分页 - 大量输出时自动切换到分页模式 2. 变量查看: - 查看大型数据结构时自动分页 - 支持在分页模式下搜索和导航 3. 堆栈跟踪: - 长堆栈信息自动分页 - 便于逐页查看调用链 ### 小结 分页输出功能通过智能的内容管理和展示方式,显著提升了调试器的可用性。它能够: 1. 自动适应不同的输出场景 2. 提供更好的用户体验 3. 有效处理大量输出内容 4. 保持输出的一致性和可读性 这个功能的设计充分考虑了实际使用场景,是调试器输出系统的重要组成部分。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/104-howto_read_separate_dwarfdata.md ================================================ # 分离式DWARF数据的工作原理 ## 概述 DWARF(Debugging With Attributed Record Formats)是一种广泛使用的调试数据格式,它提供了关于程序结构、变量和执行流程的详细信息。在Linux/AMD64平台上,调试信息通常直接嵌入在ELF文件本身中,但在某些平台和构建配置中,DWARF数据可能会被单独存储。 ## Linux/AMD64平台 在Linux/AMD64系统上,调试信息通常直接存储在ELF(Executable and Linkable Format)文件中。这是最常见和最直接的方法,其中: 1. 调试信息在构建过程中被编译到二进制文件中 2. DWARF数据存储在ELF文件的特殊节中(例如:`.debug_info`、`.debug_line`、`.debug_abbrev`) 3. 调试器可以直接访问这些信息,无需定位单独的文件 这种方法对于Linux/AMD64上的大多数开发场景来说既简单又高效。 ## 分离式DWARF数据 然而,在某些情况下,调试信息会与主可执行文件分开存储: ### 常见场景 1. **构建系统配置**:某些构建系统被配置为生成单独的调试文件,以减小主二进制文件的大小 2. **发行版包**:Linux发行版通常会从二进制文件中剥离调试信息,并在单独的调试包中提供 3. **跨平台开发**:某些平台或工具链可能由于其架构或构建系统设计而需要单独的调试文件 ### 实现细节 当调试信息被单独存储时,通常遵循以下模式: 1. **调试包命名**:单独的调试文件通常遵循以下命名约定: - `binary.debug` - `binary.dbg` - `binary.dwo`(用于分离的DWARF对象) 2. **位置约定**:调试文件可能存储在: - 与可执行文件相同的目录 - 专用调试目录(例如:`/usr/lib/debug/`) - 构建特定的调试目录 3. **文件格式**:单独的调试文件通常是: - 仅包含调试节的ELF文件 - 特定于平台的专用调试文件格式 ## 为什么Linux/AMD64不需要特殊处理 在Linux/AMD64上,将调试信息嵌入ELF文件的标准方法就足够了,因为: 1. ELF格式得到良好支持和标准化 2. 如果需要,可以使用`strip`等工具轻松剥离调试信息 3. 平台的工具链和调试器支持成熟且全面 4. 嵌入调试信息的开销通常是可以接受的 ## 何时考虑使用分离的调试文件 虽然对Linux/AMD64来说不是必需的,但在以下情况下可能值得考虑使用分离的调试文件: 1. **二进制大小至关重要**:当主二进制文件需要尽可能小时 2. **跨平台开发**:当目标平台需要分离的调试文件时 3. **发行要求**:当遵循特定于平台的发行指南时 4. **构建系统约束**:当使用强制分离调试文件的构建系统时 ## tinydbg中的调试信息目录配置 在tinydbg中,通过`debug-info-directories`配置项可以指定额外的调试信息搜索路径。这个配置的工作方式如下: 1. **配置格式**: - 可以配置多个目录路径 - 路径之间使用系统特定的分隔符(Linux/Unix系统使用冒号`:`) - 例如:`/usr/lib/debug:/usr/local/lib/debug` 2. **搜索机制**: - 当调试器需要查找调试信息时,会按照以下顺序搜索: 1. 首先在ELF文件内部查找(对于Linux/AMD64平台) 2. 如果内部没有找到,则遍历`debug-info-directories`中配置的所有目录 3. 在每个目录中,根据可执行文件的路径构建对应的调试文件路径 3. **路径构建规则**: - 对于给定的可执行文件路径,调试器会: 1. 提取可执行文件的完整路径 2. 在配置的目录中查找相同路径的文件 3. 如果找到匹配的文件,则尝试从中读取调试信息 4. **实际应用示例**: ``` 可执行文件路径:/usr/bin/program 调试信息目录:/usr/lib/debug 最终查找路径:/usr/lib/debug/usr/bin/program.debug ``` 5. **配置建议**: - 对于Linux/AMD64平台,通常不需要配置此选项 - 在需要支持其他平台或特殊构建配置时,可以添加相应的调试信息目录 - 建议将常用的调试信息目录添加到配置中,以提高调试效率 ## 结论 虽然分离式DWARF数据处理对Linux/AMD64平台来说不是必需的,但了解它的工作原理对于以下方面很重要: 1. 跨平台开发 2. 使用不同的构建系统 3. 理解各种环境中的调试信息管理 4. 支持需要分离调试文件的平台 选择嵌入式还是分离式调试信息应该基于开发环境和目标平台的具体要求。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/105-howto_guess_substitutepath.md ================================================ ## Guess SubstitutePath Automatically 在调试过程中,源代码路径映射是一个重要的问题。本文详细解释 Delve 调试器中的 substitutePath 功能是如何工作的。 ### 路径映射的挑战 在调试过程中,我们面临两种主要的路径映射挑战: #### Go 标准库源码映射 - **问题**:客户端调试机器上的 Go 源码与服务器上目标程序构建时使用的 Go 源码路径不一致 - **解决方案**: - 首先检查 Go 版本是否一致 - 如果版本不一致,直接不展示源码 - 如果版本一致,则尝试进行路径映射 #### 待调试程序源码映射 - **问题**:客户端调试机器上的程序源码与目标机器上程序构建时的源码路径不一致 - **解决方案**: - 尽可能保持源码一致性 - 通过模块路径和包路径进行映射 ### 映射猜测的工作原理 #### 输入参数 ```go type GuessSubstitutePathIn struct { ClientModuleDirectories map[string]string // 客户端模块目录映射 ClientGOROOT string // 客户端 Go 安装路径 ImportPathOfMainPackage string // 主包的导入路径 } ``` #### 核心算法 1. **收集信息**: - 从二进制文件中提取所有函数信息 - 获取每个函数的包名和编译单元信息 - 记录服务器端的 GOROOT 路径 2. **模块路径分析**: - 对每个函数,分析其所属的包和模块 - 建立包名到模块名的映射关系 - 排除内联函数的干扰 3. **路径匹配**: - 使用统计方法确定最可能的路径映射 - 设置最小证据数(minEvidence = 10) - 设置决策阈值(decisionThreshold = 0.8) 4. **生成映射**: - 为每个模块生成服务器端到客户端的路径映射 - 处理 GOROOT 的特殊映射 #### 关键代码逻辑 ```go // 统计每个可能的服务器端目录 serverMod2DirCandidate[fnmod][dir]++ // 当收集到足够的证据时进行决策 if n > minEvidence && float64(serverMod2DirCandidate[fnmod][best])/float64(n) > decisionThreshold { serverMod2Dir[fnmod] = best } ``` ### 实际应用示例 #### Go 标准库映射 ``` 服务器端:/usr/local/go/src/runtime/main.go 客户端:/home/user/go/src/runtime/main.go 映射:/usr/local/go -> /home/user/go ``` #### 项目源码映射 ``` 服务器端:/build/src/github.com/user/project/main.go 客户端:/home/user/project/main.go 映射:/build/src/github.com/user/project -> /home/user/project ``` ### 最佳实践 1. **版本一致性**: - 确保客户端和目标程序使用相同版本的 Go - 不同版本时直接禁用源码显示 2. **源码管理**: - 保持客户端和目标程序的源码结构一致 - 使用版本控制系统确保源码同步 3. **模块路径**: - 正确设置模块路径 - 确保客户端模块目录映射准确 ### 总结 SubstitutePath 功能通过智能分析二进制文件中的调试信息,自动建立服务器端和客户端之间的路径映射关系。这个功能对于远程调试和跨环境调试特别重要,它能够确保调试器正确找到和显示源代码文件。 通过合理的配置和源码管理,我们可以充分利用这个功能,提高调试效率。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/106-howto_redirect_target_io.md ================================================ ## 进程IO重定向 ### 为什么需要支持输入输出重定向? 在调试程序时,控制程序的输入输出流是非常必要的,原因如下: 1. **交互式程序**:许多程序需要用户交互输入。如果没有适当的重定向支持,调试这类程序将会变得困难或不可能。 2. **测试和自动化**:重定向输入输出允许进行自动化测试场景,可以程序化地提供输入并捕获输出进行验证。 3. **调试环境控制**:有时我们需要将调试器的输入输出与目标程序的输入输出分开,以避免混淆并保持清晰的调试会话。 ### tinydbg中的重定向方法 tinydbg提供了两种主要方法来控制目标程序的输入输出: #### 1. TTY重定向(`--tty`) `--tty`选项允许你指定一个TTY设备用于目标程序的输入输出。这对于需要正确终端界面的交互式程序特别有用。 使用方法: ```bash tinydbg debug --tty /dev/pts/X main.go ``` #### 2. 文件重定向(`-r`) `-r`选项允许你将目标程序的输入输出重定向到文件。这对于非交互式程序或需要捕获输出进行后续分析的情况很有用。 使用方法: ```bash tinydbg debug -r stdin=in.txt,stdout=out.txt,stderr=err.txt main.go ``` #### 实现细节 当启动调试会话时,tinydbg通过以下过程处理标准输入输出流(stdin、stdout、stderr)的重定向: 1. 对于TTY重定向: - 打开指定的TTY设备 - 将目标程序的文件描述符重定向到这个TTY - 这允许与目标程序进行适当的终端交互 ```go // TTY重定向实现 func setupTTY(cmd *exec.Cmd, ttyPath string) error { tty, err := os.OpenFile(ttyPath, os.O_RDWR, 0) if err != nil { return fmt.Errorf("open tty: %v", err) } // 设置标准输入输出 cmd.Stdin = tty cmd.Stdout = tty cmd.Stderr = tty // 设置进程属性 cmd.SysProcAttr = &syscall.SysProcAttr{ Setctty: true, Setsid: true, } return nil } ``` 2. 对于文件重定向: - 打开指定的文件 - 将目标程序的文件描述符重定向到这些文件 - 这实现了输入输出的捕获和重放功能 在Go程序中实现重定向时,我们主要通过设置 `os/exec.Cmd` 的 `SysProcAttr` 和标准输入输出来实现: ```go // 文件重定向实现 func setupFileRedirection(cmd *exec.Cmd, stdin, stdout, stderr string) error { // 设置标准输入 if stdin != "" { stdinFile, err := os.OpenFile(stdin, os.O_RDONLY, 0) if err != nil { return fmt.Errorf("open stdin file: %v", err) } cmd.Stdin = stdinFile } // 设置标准输出 if stdout != "" { stdoutFile, err := os.OpenFile(stdout, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("open stdout file: %v", err) } cmd.Stdout = stdoutFile } // 设置标准错误 if stderr != "" { stderrFile, err := os.OpenFile(stderr, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) if err != nil { return fmt.Errorf("open stderr file: %v", err) } cmd.Stderr = stderrFile } return nil } ``` ### 测试示例 假定我们有如下程序,这个程序涉及到输入输出: ```go package main import ( "bufio" "fmt" "os" "strings" ) func main() { fmt.Println("TTY Demo Program") fmt.Println("Type something and press Enter (type 'quit' to exit):") scanner := bufio.NewScanner(os.Stdin) for { fmt.Print("> ") if !scanner.Scan() { break } input := scanner.Text() if strings.ToLower(input) == "quit" { fmt.Println("Goodbye!") break } fmt.Printf("You typed: %s\n", input) } if err := scanner.Err(); err != nil { fmt.Fprintf(os.Stderr, "Error reading input: %v\n", err) os.Exit(1) } } ``` 下面我们看看使用 `-tty` 和 `-r` 重定向进行调试的过程。 #### TTY重定向示例 让我们通过 `tty_demo`程序来看一个实际的例子: 1. 首先,使用socat创建一个新的PTY对: ```bash socat -d -d pty,raw,echo=0 pty,raw,echo=0 ``` 2. 记下输出中的两个PTY路径(例如,`/dev/pts/23`和 `/dev/pts/24`) 3. 在一个终端中,使用第一个PTY运行程序: ```bash tinydbg debug --tty /dev/pts/23 main.go ``` 4. 在另一个终端中,你可以使用以下方式与程序交互: ```bash socat - /dev/pts/24 ``` 程序将: - 打印欢迎信息 - 等待你的输入 - 回显你输入的内容 - 继续运行直到你输入'quit' 示例会话: ``` TTY Demo Program Type something and press Enter (type 'quit' to exit): > hello You typed: hello > world You typed: world > quit Goodbye! ``` #### 文件重定向示例 要测试文件重定向,你可以: 1. 创建用于重定向的文件input.txt,output.txt 2. 使用重定向运行程序: ```bash tinydbg debug -r stdin=input.txt,stdout=output.txt,stderr=output.txt main.go ``` 3. 预先或者调试期间,将希望输入的数据写到文件,如:`echo "data..." >> input.txt`。 4. 通过 `tail -f output.txt` 观察程序输出。 5. 执行调试过程。 让我们看一个完整的文件重定向测试示例: ```bash ## 1. 创建输入文件 cat > input.txt << EOF hello world quit EOF ## 2. 创建空的输出文件 touch output.txt ## 3. 启动程序并重定向 tinydbg debug -r stdin=input.txt,stdout=output.txt,stderr=output.txt main.go ## 4. 在另一个终端中观察输出 tail -f output.txt ``` 预期的输出文件内容: ``` TTY Demo Program Type something and press Enter (type 'quit' to exit): > hello You typed: hello > world You typed: world > quit Goodbye! ``` #### 两种方式对比 使用文件进行重定向的方式,想必 `socat - /dev/pts/X` 的方式相比,可能大家更倾向于使用,因为它不需要你去执行不太熟悉的socat、tmux、screen之类的涉及到tty操作创建、读写的操作,但是明显 `socat - /dev/pts/X` 可以同时操作读写更方便。不过使用文件重定向在调试器的自动化测试过程中可能是一种更加稳定有效的方式。 ### 本节小结 tinydbg的重定向支持提供了灵活的方式来控制目标程序的输入输出流,使得调试交互式和非交互式程序都变得更加容易。`--tty`选项特别适用于需要终端交互的程序,而 `-r`选项则提供了一种通过文件捕获和重放输入输出的方式。 这些特性使tinydbg更加通用,适用于更广泛的调试场景,从简单的命令行工具到复杂的交互式应用程序。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/107-howto_customize_tinydbg.md ================================================ ## tinydbg 配置系统设计实现 tinydbg 提供了一个灵活的配置系统,允许用户根据自己的使用习惯自定义调试器的行为。本文将详细介绍配置系统的设计实现。 ### 配置命令使用 tinydbg 提供了以下配置命令: 1. `config -list`: 列出所有可用的配置项及其当前值 2. `config -save`: 将当前配置保存到配置文件 3. `config `: 设置指定配置项的值 ### 支持的配置项 tinydbg 支持以下配置项: 1. **命令别名 (aliases)** - 允许为命令创建别名 - 例如:`config alias print p` 将 `p` 设置为 `print` 命令的别名 2. **源代码路径替换规则 (substitute-path)** - 用于重写程序调试信息中存储的源代码路径 - 当源代码在编译和调试之间被移动到不同位置时特别有用 - 支持以下操作: - `config substitute-path `: 添加替换规则 - `config substitute-path `: 删除指定规则 - `config substitute-path -clear`: 清除所有规则 - `config substitute-path -guess`: 自动猜测替换规则 3. **字符串长度限制 (max-string-len)** - 控制命令打印、locals、args 和 vars 时读取的最大字符串长度 - 默认值:64 4. **数组值限制 (max-array-values)** - 控制命令打印、locals、args 和 vars 时读取的最大数组项数 - 默认值:64 5. **变量递归深度 (max-variable-recurse)** - 控制嵌套结构体成员、数组和切片项以及解引用指针的输出评估深度 - 默认值:1 6. **反汇编风格 (disassemble-flavor)** - 允许用户指定汇编输出的语法风格 - 可选值:"intel"(默认)、"gnu"、"go" 7. **位置表达式显示 (show-location-expr)** - 控制 whatis 命令是否打印其参数的 DWARF 位置表达式 8. **源代码列表颜色设置** - `source-list-line-color`: 源代码行号颜色 - `source-list-arrow-color`: 源代码箭头颜色 - `source-list-keyword-color`: 源代码关键字颜色 - `source-list-string-color`: 源代码字符串颜色 - `source-list-number-color`: 源代码数字颜色 - `source-list-comment-color`: 源代码注释颜色 - `source-list-tab-color`: 源代码制表符颜色 9. **其他显示设置** - `prompt-color`: 提示行颜色 - `stacktrace-function-color`: 堆栈跟踪中函数名的颜色 - `stacktrace-basename-color`: 堆栈跟踪中路径基本名称的颜色 - `source-list-line-count`: 调用 printfile() 时在光标上下显示的行数 - `position`: 控制程序当前位置的显示方式(source/disassembly/default) - `tab`: 控制源代码中遇到 '\t' 时打印的内容 ### 配置文件存储 配置文件存储在以下位置: 1. 如果设置了 `XDG_CONFIG_HOME` 环境变量: - `$XDG_CONFIG_HOME/tinydbg/config.yml` 2. 在 Linux 系统上: - `$HOME/.config/tinydbg/config.yml` 3. 其他系统: - `$HOME/.tinydbg/config.yml` ### 配置实现细节 #### 配置加载 配置系统通过 `pkg/config/config.go` 中的 `LoadConfig()` 函数加载配置: 1. 首先检查并创建配置目录 2. 检查是否存在旧版本的配置文件,如果存在则迁移到新位置 3. 打开配置文件,如果不存在则创建默认配置 4. 使用 YAML 解析器将配置文件内容解析到 `Config` 结构体 #### 配置应用 配置在调试器中的主要应用点: 1. **命令别名** - 在 `DebugSession` 初始化时通过 `cmds.Merge(conf.Aliases)` 合并到命令系统中 - 允许用户使用自定义的短命令 2. **路径替换** - 通过 `substitutePath()` 方法应用路径替换规则 - 在查找源代码位置时使用,确保调试器能找到正确的源文件 3. **变量加载配置** - 通过 `loadConfig()` 方法将配置转换为 `api.LoadConfig` - 影响变量查看命令(如 print、locals、args 等)的行为 - 控制字符串长度、数组大小和递归深度等限制 4. **显示设置** - 影响调试器的输出格式和颜色 - 通过终端输出函数应用颜色设置 - 控制源代码列表和堆栈跟踪的显示方式 #### 配置保存 配置通过 `SaveConfig()` 函数保存: 1. 将 `Config` 结构体序列化为 YAML 格式 2. 写入到配置文件 3. 保持用户的自定义设置持久化 ### 使用示例 1. 设置命令别名: ``` config alias print p config alias next n ``` 2. 配置源代码路径替换: ``` config substitute-path /original/path /new/path ``` 3. 调整变量显示限制: ``` config max-string-len 128 config max-array-values 100 config max-variable-recurse 2 ``` 4. 自定义显示设置: ``` config source-list-line-count 10 config disassemble-flavor gnu ``` 这些配置可以帮助用户根据自己的需求优化调试体验,提高调试效率。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/108-howto_accept_multiclient.md ================================================ ## acceptMulti 模式的工作原理 ### 为什么要支持多客户端调试 在社区讨论如何解决这个问题 [Extend Delve backend RPC API to be able to make a Delve REPL front-end console in a IDE](https://github.com/go-delve/delve/issues/383) 时,有位网友反馈在调试会话调试期间,此时如果有另一个client尝试连接(dlv connect),此时会报错:“An existing connection was forcibly closed by the remote host.” 因为这个问题,所以dlv维护人员才支持了--accept-multiclient选项,允许多客户端连接。 尽管关于这个 `--accept-multiclient` 选项的讨论仅有一句话这么简单,但是如果没有这个选项,却会给开发者调试带来很多不便,下面我举个远程调试的例子。 1. 我们设置执行命令 `tinydbg exec ./binary --headless -- ` 运行被调试程序,或者 `tinydbg attach ` 来跟踪已经在执行的进程。如果是需要指定启动参数,这个过程并不一定是很简答的。 2. 然后执行 `dlv connect ` 进行调试; 3. 或者,希望配合tinydbg 命令行和vscode、goland图形化调试界面使用; 4. 或者,调试期间遇到瓶颈,希望其他人来协助调试、共同定位问题; 5. 或者,执行完这次调试会话,但是不想被调试进程结束,还希望用它来执行后续可能的调试活动; 这里列出的调试场景,要求我们的调试器backend必须能够支持接受多个调试客户端进行连接、共同调试的能力。这个场景和诉求是真实存在的,所以 `--accept-multiclient` 支持仅有几行代码变更,但是对于我们更便利地调试而言,却是非常重要的。 ### 单客户端 vs 多客户端模式 tinydbg 支持两种调试服务器模式: 1. 单客户端模式(--headless 不带 --accept-multiclient) - 服务器只接受一个客户端连接 - 当第一个客户端连接并退出时,调试服务器会自动关闭 - 这种模式适合单次调试会话,调试完成后自动清理资源 2. 多客户端模式(--headless --accept-multiclient) - 服务器会持续运行,等待多个客户端连接 - 每个客户端可以独立连接和断开 - 所有客户端共享相同的调试状态(断点、观察点等) - 被调试程序会持续运行,直到所有客户端都断开连接 这两种模式的主要区别在于服务器对客户端连接的处理方式,以及调试会话的生命周期管理。 实现原理如下,关键之处在于接受1个入连接请求后,后续入连接请求是拒绝还是允许: ```go go func() { defer s.listener.Close() for { c, err := s.listener.Accept() if err != nil { select { case <-s.stopChan: return default: panic(err) } } go s.serveConnection(c) if !s.config.AcceptMulti { break } } }() ``` ### 多客户端模式的可能应用场景 多客户端模式特别适用于以下场景: 1. **连续调试** - 多个客户端可以先后连接 - 不需要重启被调试程序 - 适合长时间运行的调试任务 2. **多工具协同** - 可以同时使用命令行 UI 和 VSCode 调试面板 - 不同工具可以共享相同的调试状态 - 便于使用不同工具的优势 3. **团队协作** - 多个开发者可以同时连接到同一个调试会话 - 共享断点、观察点等设置 - 便于团队协作解决复杂问题 ### 注意事项 1. **API 非重入性** - 虽然支持多客户端连接,但 API 不是可重入的 - 客户端需要协调使用,避免冲突 2. **模式限制** - 在非 headless 模式下,acceptMulti 选项会被忽略 - 必须同时使用 --headless 和 --accept-multiclient 3. **客户端断开处理** - 客户端断开连接时,可以选择是否继续执行被调试程序 - 使用 `quit -c` 命令可以在断开连接时继续执行程序 ### 总结 acceptMulti 模式是 tinydbg 的一个重要特性,它使得调试器能够支持多客户端连接,这对于多轮调试、多客户端调试、协作调试等场景非常有用。通过共享调试状态,多个客户端可以先后进行调试,也可以协同调试,提高调试效率。可以说 `--accept-multiclient` 支持多客户端模式,不算是一个大的特性,而是一个设计实现上必须考虑到的功能点。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/109-howto_transcript_debugging.md ================================================ ## transcript 设计与实现 ### 调试记录本 大家在执行Linux命令时,为了记录刚才一系列操作中执行的命令,以及命令的输出,通常我们会使用 `script` 命令来完成这个事情。系统学习过Linux命令行操作的读者,对此应该都不会感到陌生。 see `man 1 script`: ```bash NAME script - make typescript of terminal session SYNOPSIS script [options] [file] DESCRIPTION script makes a typescript of everything displayed on your terminal. It is useful for students who need a hardcopy record of an inter‐ active session as proof of an assignment, as the typescript file can be printed out later with lpr(1). If the argument file is given, script saves the dialogue in this file. If no filename is given, the dialogue is saved in the file typescript. ``` 其实对于调试场景,我们也很希望能拥有这样的调试能力。调试过程大多数是循序渐进的,不大可能第一遍调试就100%定位到问题,同行我们需要参考之前执行的调试过程,重新发起多轮调试才能定位到问题源头。tinydbg(dlv)就设计实现了 `transcript` 命令来实现这个操作,保存您在调试会话中生成的调试命令、命令输出到一个您指定的文件中去,方便后续查看。 ### 功能概述 `transcript` 命令用于将调试会话中的命令输出记录到文件中,支持追加或覆盖模式,并可选择是否同时输出到标准输出。这个功能对于保存调试会话记录、生成调试报告或进行后续分析非常有用。 ### 执行流程 1. 用户在前端输入 `transcript [options] ` 命令。 2. 前端解析命令参数,包括: - `-t`: 如果输出文件存在则截断 - `-x`: 抑制标准输出 - `-off`: 关闭转录功能 3. 后端根据配置打开或关闭文件输出流。 4. 在调试会话中,所有命令的输出都会被写入到指定的文件中。 5. 用户可以随时通过 `transcript -off` 停止记录。 ### 关键源码片段 ```go var transcriptCmd = func(c *DebugSession) *command { return &command{ aliases: []string{"transcript"}, cmdFn: transcript, helpMsg: `Appends command output to a file. transcript [-t] [-x] transcript -off Output of Delve's command is appended to the specified output file. If '-t' is specified and the output file exists it is truncated. If '-x' is specified output to stdout is suppressed instead. Using the -off option disables the transcript.`, } } ``` ### 流程图 ```mermaid sequenceDiagram participant User participant Debugger participant File User->>Debugger: transcript [-t] [-x] alt 新文件 Debugger->>File: 创建新文件 else 已存在文件 alt -t 选项 Debugger->>File: 截断文件 else 无 -t 选项 Debugger->>File: 追加到文件 end end loop 命令执行 User->>Debugger: 执行命令 alt -x 选项 Debugger->>File: 只写入文件 else 无 -x 选项 Debugger->>File: 写入文件 Debugger->>User: 输出到终端 end end User->>Debugger: transcript -off Debugger->>File: 关闭文件 ``` ### 小结 transcript 命令为调试会话提供了完整的输出记录功能,通过灵活的选项配置,可以满足不同的记录需求。这个功能对于调试过程的追踪、问题分析和知识分享都很有帮助。其设计充分考虑了实用性和灵活性,是调试工具中一个重要的辅助功能。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/110-howto_debug_deterministically.md ================================================ ## 如何实现确定性调试 ### 1. 问题背景 #### 1.1 Bug并不总是可以稳定复现 在软件开发和测试过程中,我们经常遇到一些难以稳定复现的bug。以软件测试中的flaky tests为例,如果我们有沉淀测试用例,团队也有例行测试用例执行检查,我们有可能会发现一些flaky tests。但是因为flaky tests出现的原因多种多样,偶现或者很难出现,所以导致难以定位。 复现问题可能比解决问题更复杂,因为通常一个问题可以稳定复现,解决它只是确定最终方法的工作量,而复现问题需要花多长时间,则可能是无法预知的,有可能不得不观察1天、一周甚至更久。 #### 1.2 调试会话中的效率问题 有时调试会话中一不小心错过感兴趣的断点位置,只能重新启动调试活动,比较低效。如restart从头开始,或者continue执行等到下一个感兴趣事件到来命中感兴趣的断点。如果能回滚刚才执行的程序语句,回到之前的状态,则有机会在感兴趣的位置设置断点,以进行更高效地调试。 ### 2. 调试难点 #### 2.1 如何进行确定性地调试 有时可能我们发现了故障,问题现场也被保留了,但是依然难以推敲先在这种情形是如何造成的。如果能将问题出现之前、问题发生时、问题表现出故障这一连串的程序执行过程全部给录制下来,并能够稳定地回放,那我们就可以进行确定性的调试。record & replay可能是对此类问题最好的解法。 #### 2.2 Record & Replay的广泛应用 当然,record & replay也不光可以用于解决此类刁钻问题,它也可以用于其他一般情景中的问题定位。因为录制和回放技术,它允许你撤销回放中的某几步操作,以此来实现撤销程序执行的指令、恢复执行前的状态,这使得我们可以在一般的调试活动中可以避免因为错过兴趣点而不得不重启调试活动,如restart从头开始,或者continue执行等到下一个感兴趣事件到来命中感兴趣的断点,然后才能再对目标问题发起debug。 ### 3. 核心问题 那么核心问题就是如何实现record & replay,record哪些内容?如何record这些内容?如何replay这些内容?调试器如何使用这些recorded traces进行精确地回放? Mozilla RR提供了record & replay的能力支持,并且RR基于这项核心技术对GDB进行了增强,可以直接将RR作为调试器前端的对应调试器后端,前后端通过GDB serial协议进行通信即可,调试器前端可以支持反向next、step、stepin、stepout、continue等操作,以实现正常执行流程下的reverse direction操作下的同等类型操作,如当前是反向执行模式,continue将执行到之前执行过的最近的断点位置处。 在这个能力基础上,Mozilla RR实现了这个宏伟的目标:"记录一次失败,你就可以无限制地进行确定性地调试"。 ### 4. RR实现细节 那么Mozilla RR是如何实现这些record & replay技术的呢?这可以参考Mozilla RR的论文以及它的文档: - 官方主页:[rr-project](https://rr-project.org/) - Github地址:[rr-debugger/rr](https://github.com/rr-debugger/rr) - 论文:[Engineering Record And Replay For Deployability Extended Technical Report](https://arxiv.org/pdf/1705.05937) #### 4.1 设计原理 RR的设计基于一个关键观察:CPU在大多数情况下是确定性的。RR识别状态和计算的边界,记录边界内所有非确定性来源和跨越边界的输入,然后通过重放非确定性和输入来重新执行边界内的计算。如果所有输入和非确定性都被真正捕获,那么重放期间边界内的状态和计算将与记录期间匹配。 #### 4.2 技术约束 RR依赖于现代硬件和操作系统特性,这些特性原本是为其他目标设计的。RR需要: - **现代Intel CPU**:支持性能计数器,用于测量应用程序进度 - **Linux内核**:支持ptrace、seccomp-bpf、perf context-switch事件等特性 - **用户空间实现**:完全在用户空间运行,不需要内核修改 #### 4.3 核心实现技术 ##### 4.3.1 系统调用拦截 RR使用ptrace来记录和重放系统调用结果和信号。为了避免非确定性数据竞争,RR一次只运行一个线程。使用CPU硬件性能计数器来测量应用程序进度,确保异步信号和上下文切换事件在正确的时刻传递。 ##### 4.3.2 进程内系统调用拦截优化 RR实现了一个新颖的进程内系统调用拦截技术来消除上下文切换,这大大减少了重要实际工作负载的录制和重放开销。这个优化依赖于现代Linux内核特性: - **seccomp-bpf**:选择性地抑制某些系统调用的ptrace陷阱 - **perf context-switch事件**:检测记录的线程在内核中阻塞 ##### 4.3.3 内存和状态管理 RR几乎保留用户空间执行的每个细节。特别是,用户空间内存和寄存器值被精确保留,这意味着CPU级控制流在记录和重放之间是相同的,内存布局也是如此。 #### 4.4 性能表现 根据RR论文的数据,对于低并行度工作负载(特别是运行Firefox测试套件的Firefox),RR的录制和重放开销小于2倍,这在可部署性相当的方案中是最低的。 #### 4.5 实际应用 RR被许多开发者日常使用,作为高效反向执行调试器的基础,可以处理复杂的应用程序,如: - Samba - Firefox - Chromium - QEMU - LibreOffice - Wine ### 5. 参考资料 - **论文**:[Engineering Record And Replay For Deployability](https://arxiv.org/pdf/1705.05937) - **官方主页**:[https://rr-project.org/](https://rr-project.org/) - **GitHub地址**:[https://github.com/rr-debugger/rr](https://github.com/rr-debugger/rr) ### 6. 总结 确定性调试通过record & replay技术解决了传统调试中的两个核心问题:难以复现的bug和调试会话中的效率损失。Mozilla RR通过巧妙利用现代硬件和操作系统的特性,实现了完全用户空间的record & replay系统,为开发者提供了强大的调试工具。 RR的成功证明了在标准硬件和软件上构建实用的record & replay系统是可能的,这为未来的调试工具开发提供了重要的参考和基础。 ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/111-howto-integrate-with-rr.md ================================================ ================================================ FILE: book/9-develop-sym-debugger/3-高级功能扩展/README.md ================================================ ## 高级功能扩展 在前面的章节中,我们详细介绍了调试器的核心功能设计与实现,包括进程控制、断点管理、调用栈分析等基础特性。这些功能构成了一个调试器的核心框架。 然而,要打造一个真正实用的调试器,仅有这些基础功能是不够的。还有许多细节特性和扩展功能,虽然不是必需的,但能大大提升调试器的易用性和实用价值。这些功能包括: - 集成脚本引擎(starlark),支持用户编写自动化调试脚本 - 基于eBPF的轻量级跟踪功能,实现低开销的程序行为分析 - 语法高亮显示,提升源码阅读体验 - 分页输出支持,优化大量调试信息的展示 - 读取分离存储的调试信息,支持stripped二进制的调试 - 自动推测源码路径映射,简化源码定位配置 - 目标进程IO重定向,灵活控制程序输入输出 - ... 这一章节,我们将深入介绍这些扩展功能的实现原理和具体细节。这些内容将帮助你打造一个更加完善和易用的调试工具。 让我们开始探索这些有趣且实用的扩展特性。 ================================================ FILE: book/9-develop-sym-debugger/README.md ================================================ ## 调试器:开发者的得力助手 ### 无法避免的bug 从打孔卡片时代开始,到机器指令、汇编语言,再到现在五花八门的高级编程语言,编程语言的表达能力和易用性不断提升,编译器和静态分析工具也日益完善,开发人员对计算机系统的理解也越来越深入。然而,bug依然不能完全避免,成为软件开发中永恒的挑战。 犯错误并不可怕,能够及时发现错误、定位问题根源并纠正错误才是关键所在。另外,bug的存在并不意味着它一定会表现出明显的"症状"。有些bug是必现的,有些是间歇性出现的,有些则潜伏很久才会表现出症状,而有些可能永远不会被触发。有些flaky tests更令人头疼,即使是相同的输入也不一定能复现问题。bug潜藏深浅不一,进一步增加了它们被定位和修复的难度。 ### 高效调试方法论 要高效解决bug,首要任务是在问题出现时及时保留现场。这包括隔离问题服务实例供开发人员调查、生成进程core文件以供分析等措施。这些都是为后续深入排查打下基础。及时保留问题现场只是高效解决问题的第一步,还需要有趁手的"兵器"来深入"症状"内部来一探究竟、定位bug的源头。 有些有经验的开发人员,会考虑通过错误日志、走读代码来发现定位查找bug原因,实践证明,这也是一个比较实用的方式,但是任何方法都有它的适用范畴。有些极端的声音,比如,"你不需要一个调试器"。而真实情况是,并非所有的bug都可以靠日志来简单的定位,因为不可能通过日志来跟踪每一行语句的执行前后的状态变化。 根据具体问题,识别并选择合适的调试方法,才是更科学的做法。 ### 调试器的价值 调试器远不止是一个简单的查错工具。它不仅能帮助定位bug,更是探索和理解系统内部运作的绝佳工具。通过调试器,我们获得了一个"上帝视角",可以观察任何系统的运行细节,深入理解各种算法的执行过程。对于那些渴望知其然更知其所以然的开发者来说,调试器就像是打开了一扇通向知识宝库的大门。 ================================================ FILE: book/README.md ================================================ # 1 软件调试的艺术:Go语言调试器设计实现内幕 封面图片 ## 1.1 作者简介 大家好,我是张杰,目前担任腾讯(深圳)科技有限公司高级后台开发工程师。在腾讯的职业生涯中,我曾参与多个关键项目,包括 Now直播、腾讯看点、内容中台、QQ浏览器、三角洲行动 等产品。2018年7月–2019年3月,我为团队量身打造了微服务框架 GoNeat,它大幅提升了研发效率,并通过一系列可观测性建设显著优化了问题排查效率。2019年7月起,我投身公司技术治理工作,担任架构师和核心开发者,参与了腾讯微服务框架 [Tencent/trpc](https://github.com/Tencent/trpc) 的设计与实现。同时,我还积极探索 EPC 工程效能提升,并推动其在团队内落地。在 PCG/IEG 代码委员会期间,参与了公司代码规范讨论制定、lint工具开发、代码评审、工程素养培训相关工作。2022年7月,我加入 三角洲行动 项目组,主导完成了一系列基础技术建设、核心系统设计与上线筹备工作。这些经历不仅让我深入了解各类业务挑战与解决方案,也为我持续磨练技术、实现个人成长提供了宝贵机会。 我是高中毕业之后才开始接触计算机编程的,再经过大学系统性学习、毕业参加工作至今,一转眼十年过去。但是,计算机技术的发展始终吸引着我去学习、去思考、去探索更广阔的应用场景来丰富现实生活,而且乐此不疲。而“开源”让我接触了更大的世界,我很欣赏那种技术精湛、乐于分享甚至连指尖都洋溢着才华与天赋的工程师,并将这类人作为我的榜样。“在开源中学习,在开源中贡献”,写博客、写书、分享,也算是我对这种理念的一种支持和坚守吧。 ## 1.2 为什么要写这本书 ### 1.2.1 Go很有意思 这里首先提一下学习go语言的历程,如果不是因为go,我也不会接触到delve,便不会有这本书的诞生。 - 2016年开始接触go,起初还有点抵触,比如坚持用C来模拟Go特性,详见 [C实现Go风格并发](https://www.hitzhangjie.pro/libmill-book/); - 2018年 c\c++\java 服务器开发有了些沉淀,认识到go的优雅并开始在项目中使用; - 多年实操、go源码、设计提案、调试跟踪等方式深入了解了go的设计实现,详见 [Go设计实现内幕](https://www.hitzhangjie.pro/go-internals-v0/); - 2025年开始,将过去多年的项目踩坑经验整理成书,陆续更新到公众号,详见 [Go"安全"编程](https://mp.weixin.qq.com/mp/appmsgalbum?__biz=MzU2NjY3MTc3MQ==&action=getalbum&album_id=3544414370141700100#wechat_redirect); 如今go语言越来越完善、生态越来越好,go语言已经成为最受欢迎的编程语言之一。 ### 1.2.2 调试器很有意思 计算机是个系统性工程,比如“一个程序是如何运行的”,看似简单的问题牵扯到了编程语言、编译器、链接器、操作系统、处理器、内存、总线控制等方方面面的内容,要掌握这些内容需要长时间的学习与实践。 新手学习go时,难免会遇到通过“调试”来排查问题的的情况,可能是排查逻辑bug or 语言设计如运行时、内存管理等复杂逻辑。[delve](https://github.com/go-delve/delve) 作为最流行的go语言调试器,在使用delve的过程中,我联想到可以从调试器角度切入来窥探计算机世界的秘密。不管是什么编程语言,只要有调试信息支持,总能借助调试器来窥探进程的运行过程。形象点的话可以联想下FPS游戏,一倍镜窥探代码执行,二倍镜窥探变量,三倍镜窥探类型系统,四倍镜窥探硬件特性……有什么能逃过调试器的法眼呢? > ps: 如果较真,内核就需要内核级调试器,硬件就需要更加专门的手段来观察了 …… 这超出了本书的范畴。 本书希望能从调试器角度出发让开发者更好地理解编程语言、编译器、链接器、操作系统、调试器、硬件之间的联系,相比书籍课本上割裂式的教学,这样更能让读者感受到它们彼此间密切的协作,可以让开发者从更宏观的视角来审视软件开发的全貌。读完本书,开发者不光能掌握**go程序的高级调试技巧**,在日后的问题排查中事半功倍;开发者还将掌握**调试器开发和定制化能力**,如修改、扩展dlv调试器功能、编写starlark脚本实现dlv自动化调试等等;当然,也加深了对计算机系统全局的认识。 ### 1.2.3 工程素养传承 有资历的工程师可能认为他们不再需要调试器,靠以往的经验和思路就可以高效定位问题。没错,资深工程确实减少了对调试器的依赖,但这不能论证调试器无用,只能说明某开发者在其专精的领域内排查问题已经得心应手。所以,我们不能简单地认同“~~**我们真的不需要调试器**~~”这个观点。实际上,是否需要调试器,更取决于具体问题的复杂性、时间精力的投入、现有排查手段的效率,以及对最终结果的要求等多方面因素的权衡。很多时候不是调试器无用,而是在当前情况下还没有到必须用调试器来解决问题的地步而已。 对一些稍显复杂的问题,或者为加深理解的一次探索,借助调试器的一次有效追根溯源过程,就可能将一类“疑惑”给彻底终结。这才是我们最需要调试器介入的情况。当然了,通过调试器调试跟踪也只是众多Debug手段中的一种而已。在我感兴趣的一些领域中,我就特别需要调试器来辅助跟踪代码执行路径的情况,当然这里的代码逻辑也不是写几个微服务调用、ifelse所能比的,这种情况下加日志、编译测试、走读代码显然都不是特别高效的选择。 > ps: 别管谁跟你说“~~**我们真的不需要调试器**~~”,你都要保持质疑的态度,“这家伙说的到底是什么情景下不需要”,不要被轻易带偏,更不要因为自己不精通“调试”,还想“偷懒”,就欣欣然接受别人的观点。不如多思考下,一个聪明人会如何进行调试? 我理解,调试器领域也确实沉淀了一些值得拿出来分享的东西,比如DWARF标准演进以支持更多语言特性,调试器架构演进以支持更多调试场景,如与IDE集成、跨平台调试、远程调试等,以及录制回放技术(record & replay)以实现确定性调试,等等。这些东西如果只停留在成熟稳定的项目代码中,或者少数几个核心贡献者的大脑里,未免太可惜了。 ## 1.3 本书内容 本书以“从零开发Go语言调试器”为主线,旨在通过实战方式串联软件调试这一复杂领域涉及的多项核心技术。尽管业界已经有如gdb、delve这样成熟的调试器,我们重拾轮子,借助调试器开发实践,将go语言、操作系统、编译器、链接器、硬件架构等知识融会贯通,帮助开发者建立起对软件底层机制的系统性认识。开发过程中接触到大量命令行工具开发、网络服务器开发、系统级编程以及需要深入理解go语言设计实现的内容,相信读者朋友们一定会有所收获。 本书内容结构清晰,紧密围绕“从零开发Go语言调试器”的主线展开,依托各章节循序渐进,逐步深入。下面结合目录梳理各章节的核心内容: - **第2-3章:前言与常用术语** —— 针对新手做简要引导,罗列调试器开发相关的核心术语,为后续阅读扫清知识障碍。 - **第4章:软件调试基础** —— 介绍调试的基本概念、目的、依赖条件和反调试技术,帮助读者建立调试的整体认知和背景知识。 - **第5章:走进调试器开发** —— 从零带读者设计和实现一个简单调试器雏形,讲解核心模块架构设计,分析不同实现方案及各自优劣,奠定后续工程基础。 - **第6章:开发Go指令级调试器godbg** —— 详细拆解指令级调试器的各主要功能实现:从进程启动、跟踪,到断点管理、单步执行、内存/寄存器查看与修改、支持多线程/多进程调试,直至ASLR机制与更多进阶话题,帮助读者系统掌握底层调试原理及实践。 - **第7章:挺进符号级调试器** —— 深入讲解Go程序二进制构建原理,elf文件、符号表、DWARF调试信息等知识,以及符号级调试器的功能模块实现,为观测和操作高层代码对象奠定基础。 - **第8章:调试信息标准DWARF剖析** —— 对DWARF标准进行详细剖析,帮助读者理解DWARF的结构、内容、以及如何使用DWARF进行调试,包括如何用DWARF对源码进行描述、如何解析DWARF数据。 - **第9章:实现完整的Go符号级调试器tinydbg** —— 以实际工程推进为主线,逐步攻克核心功能(源码级断点、单步、变量/栈帧/协程/goroutine等信息展示、watch、trace等),带领读者完成一个可用的Go符号级调试器,以及高级功能扩展。 - **第10~12章:更多高级话题** —— 调试器、日志系统、可观测系统的演进,bisect在debug中的引用,ebpf、录制重放、分布式系统调试、AI辅助调试,以及现代调试技术展望等相关内容,扩宽技术视野。 ## 1.4 示例代码 **本书对应的示例代码,包括指令级调试器godbg、符号级调试器tinydbg、以及探索测试类代码golang-debugger-lessons:** 1. [**hitzhangjie/godbg**](https://github.com/hitzhangjie/godbg),该项目提供了一个功能相对完整的 “**指令级调试器**” 实现,供读者测试、学习了解整体代码组织; 2. [**hitzhangjie/tinydbg**](https://github.com/hitzhangjie/tinydbg),该项目提供了一个可用标准的 ”**符号级调试器**“ 实现,由 [dlv](https://github.com/go-delve/delve) 裁剪调整而来(剔除了与linux/amd64无关扩展,及高级功能); 3. [**hitzhangjie/golang-debugger-lessons**](https://github.com/hitzhangjie/golang-debugger-lessons),该项目按章节组织目录,每节实现目标在godbg、tinydbg落地前,会先在此项目中编写demo进行验证; ps: [**hitzhangjie/godbg-debugger-lessons/0-godbg**](https://github.com/hitzhangjie/godbg),该submodule即hitzhangjie/godbg。 **关于示例代码对应测试环境的一些补充说明**: - godbg项目中提供了vscode的容器化开发配置 (详见./devcontainer/devcontainer.json),指定的基础开发镜像是CentOS Stream9+go1.22.4。如果您更喜欢使用容器开发环境,您可以在vscode中安装插件 "Dev Conatiners",在准备就绪后可以唤出Command Palette并选择 "Dev Containers: Reopen in Container"。 - 您也可以直接在Linux虚拟机或者Linux物理机中进行测试,请务必注意开发环境设置,Go版本必须>=go1.22并且 ps:关于上述Go版本约束的相关说明 > > 1. Go版本不能低于go1.22:新版本才有的标准库函数旧版本无法正常编译,即使侥幸编译通过,实际产物也可能与书中内容描述有差异: > > - go1.13将调试信息写入.zdebug_sections,但go1.19不再写入.zdebug_ sections(写入的是.debug_ sections并通过flag指明是否开压缩); > - go1.14引入SIGURG支持抢占,如果使用更古老的go版本测试,这些SIGURG处理相关的代码永远不会被触发,可能会影响读者阅读和理解。 > 2. Go版本必须低于go1.25:因为go1.25已经推进到了DWARF v5,而本书基于Go1.13~Go1.24编写,这期间Go工具链使用的是DWARF v4; ## 1.5 联系方式 如果您有任何建议,请提[Issues](https://github.com/hitzhangjie/golang-debugger-book/issues),或邮件联系 `hit.zhangjie@gmail.com`,标题中注明来意 `GoDebugger交流`。 希望该书及相关示例,能顺利完成,也算是我磨练心性、自我提高的一种方式,如果能对大家确实起到帮助的作用那是再好不过了。借此机会,我也想向 go-delve/delve 的维护人员 [derekparker](https://github.com/derekparker)、[aarzilli](https://github.com/aarzilli) 以及其他贡献者致以诚挚的敬意,没有你们多年来的贡献、沉淀,我也没有什么好学习总结分享的。 如果喜欢本书,请点个 [Star](https://github.com/hitzhangjie/golang-debugger-book) 对作者予以支持 :) ================================================ FILE: book/SUMMARY.md ================================================ # Summary * [1 介绍](README.md) * [2 前言](2-preface/README.md) * [3 常用术语](3-terms/README.md) * [4 软件调试基础](4-basics/README.md) * [4.1 调试目的](4-basics/1-purposes.md) * [4.2 调试依赖](4-basics/2-dependencies.md) * [4.3 反调试技术](4-basics/3-countertactics.md) * [5 走进调试器开发](5-debugger-skeleton/README.md) * [5.1 设计一个调试器雏形](5-debugger-skeleton/1-debugger_skeleton.md) * [5.2 架构设计和技术方案](5-debugger-skeleton/2-debugger_solution.md) * [5.3 具体的实现方案选择](5-debugger-skeleton/3-debugger_solution_final.md) * [5.4 功能演示和实现效果](5-debugger-skeleton/4-debugger_demos.md) * [6 开发go指令级调试器](6-develop-inst-debugger/README.md) * [6.1 启动进程](6-develop-inst-debugger/1-process_start.md) * [6.2 跟踪进程](6-develop-inst-debugger/2-process_attach.md) * [6.3 启动&跟踪进程](6-develop-inst-debugger/3-process_start_attach.md) * [6.4 调试会话](6-develop-inst-debugger/4-debug-session.md) * [6.5 反汇编](6-develop-inst-debugger/5-disassemble.md) * [6.6 添加断点](6-develop-inst-debugger/6-breakpoint.md) * [6.7 列出断点](6-develop-inst-debugger/7-breakpoints.md) * [6.8 移除断点](6-develop-inst-debugger/8-clear.md) * [6.9 清空断点](6-develop-inst-debugger/9-clearall.md) * [6.10 调试器退出前的断点清理机制](6-develop-inst-debugger/10-clearall-atexit.md) * [6.11 步进操作](6-develop-inst-debugger/11-step.md) * [6.12 运行到断点](6-develop-inst-debugger/12-continue.md) * [6.13 查看内存数据](6-develop-inst-debugger/13-pmem.md) * [6.14 查看寄存器数据](6-develop-inst-debugger/14-pregs.md) * [6.15 修改内存数据](6-develop-inst-debugger/15-set-mem.md) * [6.16 修改寄存器数据](6-develop-inst-debugger/16-set-regs.md) * [6.20 调试多线程程序](6-develop-inst-debugger/20-multithread/README.md) * [6.20.1 跟踪已创建线程](6-develop-inst-debugger/20-multithread/1-trace_old_threads.md) * [6.20.2 跟踪新创建线程](6-develop-inst-debugger/20-multithread/2-trace_new_threads.md) * [6.20.3 线程执行控制 - 挂起策略](6-develop-inst-debugger/20-multithread/3-suspend_policy.md) * [6.20.4 线程执行控制 - continue](6-develop-inst-debugger/20-multithread/4-continue.md) * [6.20.5 线程执行控制 - breakpoint](6-develop-inst-debugger/20-multithread/5-breakpoint.md) * [6.21 调试多进程程序](6-develop-inst-debugger/21-multiprocess/README.md) * [6.80 认识ASLR](6-develop-inst-debugger/80-aslr.md) * [6.99 More...](6-develop-inst-debugger/99-more.md) * [7 挺进符号级调试器](7-headto-sym-debugger/README.md) * [7.1 Go构建内幕](7-headto-sym-debugger/1-how-go-build-works.md) * [7.2 理解ELF文件](7-headto-sym-debugger/2-elf.md) * [7.3 符号&符号表](7-headto-sym-debugger/3-syms.md) * [7.4 符号解析&重定位](7-headto-sym-debugger/4-syms-resolve-reloc.md) * [7.5 加载](7-headto-sym-debugger/5-loading.md) * [7.6 go标准库debug/\*](7-headto-sym-debugger/6-gopkg-debug/README.md) * [7.6.1 debug/elf](7-headto-sym-debugger/6-gopkg-debug/1-elf.md) * [7.6.2 debug/gosym](7-headto-sym-debugger/6-gopkg-debug/2-gosym.md) * [7.6.3 debug/dwarf](7-headto-sym-debugger/6-gopkg-debug/3-dwarf.md) * [7.7 挺进DWARF](7-headto-sym-debugger/7-headto-dwarf/README.md) * [7.7.1 相关的GoPackages](7-headto-sym-debugger/7-headto-dwarf/1-gopkgs-about-dwarf.md) * [7.7.2 如何破解那些细节](7-headto-sym-debugger/7-headto-dwarf/2-delve-into-internals.md) * [7.7.3 编译器生成DWARF数据](7-headto-sym-debugger/7-headto-dwarf/3-compiler-gen-dwarfdata.md) * [7.7.4 链接器生成DWARF数据](7-headto-sym-debugger/7-headto-dwarf/4-linker-gen-dwarfdata.md) * [7.7.5 可以了解DWARF了](7-headto-sym-debugger/7-headto-dwarf/5-headto-dwarf.md) * [8 调试信息标准: DWARF](8-dwarf/README.md) * [8.1 DWARF发展历史](8-dwarf/1-history.md) * [8.2 DWARF内容概览](8-dwarf/2-overview.md) * [8.3 DWARF数据分类](8-dwarf/3-dwarfdata.md) * [8.4 DIE详细介绍](8-dwarf/4-die/README.md) * [8.4.1 DIE描述数据和类型](8-dwarf/4-die/1-desc-data-type.md) * [8.4.2 位置数据](8-dwarf/4-die/2-desc-locations.md) * [8.4.3 DIE描述可执行代码](8-dwarf/4-die/3-desc-code.md) * [8.4.4 DIE数据编码](8-dwarf/4-die/4-encoding.md) * [8.5 其他调试数据](8-dwarf/5-other/README.md) * [8.5.1 加速访问](8-dwarf/5-other/1-accelerated-access.md) * [8.5.2 行号表信息](8-dwarf/5-other/2-lineno-table.md) * [8.5.3 调用栈帧信息](8-dwarf/5-other/3-callframe-info.md) * [8.5.4 宏信息](8-dwarf/5-other/4-macro-info.md) * [8.5.5 变长数据](8-dwarf/5-other/5-varlen-data.md) * [8.5.6 压缩DWARF数据](8-dwarf/5-other/6-shrink-data.md) * [8.5.7 ELF Sections](8-dwarf/5-other/7-elf-sections.md) * [8.6 DWARF解析及应用](8-dwarf/6-practices.md) * [8.7 本章总结](8-dwarf/7-summary.md) * [9 开发go符号级调试器](9-develop-sym-debugger/README.md) * [9.1 架构设计](9-develop-sym-debugger/1-架构设计/README.md) * [9.1.1 现代调试器架构](9-develop-sym-debugger/1-架构设计/1-现代调试器架构.md) * [9.1.2 前端UI层设计](9-develop-sym-debugger/1-架构设计/2-前端UI层设计.md) * [9.1.3 Service层设计](9-develop-sym-debugger/1-架构设计/3-Service层设计.md) * [9.1.4 后端符号层设计](9-develop-sym-debugger/1-架构设计/4-后端符号层设计.md) * [9.1.5 后端目标层设计](9-develop-sym-debugger/1-架构设计/5-后端目标层设计.md) * [9.1.6 日志系统设计](9-develop-sym-debugger/1-架构设计/6-日志系统设计.md) * [9.2 核心调试逻辑](9-develop-sym-debugger/2-核心调试逻辑/README.md) * [9.2.00 核心调试命令](9-develop-sym-debugger/2-核心调试逻辑/00-cmds.md) * [9.2.01 打开调试会话](9-develop-sym-debugger/2-核心调试逻辑/01-debug-session.md) * [9.2.11 tinydbg attach](9-develop-sym-debugger/2-核心调试逻辑/11-tinydbg_attach.md) * [9.2.12 tinydbg attach+waitfor](9-develop-sym-debugger/2-核心调试逻辑/12-tinydbg_attach_waitfor.md) * [9.2.13 tinydbg exec](9-develop-sym-debugger/2-核心调试逻辑/13-tinydbg_exec.md) * [9.2.14 tinydbg debug](9-develop-sym-debugger/2-核心调试逻辑/14-tinydbg_debug.md) * [9.2.15 tinydbg core - part1](9-develop-sym-debugger/2-核心调试逻辑/15-tinydbg_core1.md) * [9.2.15 tinydbg core - part2](9-develop-sym-debugger/2-核心调试逻辑/15-tinydbg_core2.md) * [9.2.16 tinydbg connect](9-develop-sym-debugger/2-核心调试逻辑/16-tinydbg_connect.md) * [9.2.17 tinydbg trace](9-develop-sym-debugger/2-核心调试逻辑/17-tinydbg_trace.md) * [9.2.19 evalexpr表达式计算引擎](9-develop-sym-debugger/2-核心调试逻辑/19-how_evalexpr_works.md) * [9.2.20 locspec解析与地址转换](9-develop-sym-debugger/2-核心调试逻辑/20-how_locspec_works.md) * [9.2.21 tinydbg> disass](9-develop-sym-debugger/2-核心调试逻辑/21-debug_disassemble.md) * [9.2.22 tinydbg> breakpoint - part1: 断点精细化管理](9-develop-sym-debugger/2-核心调试逻辑/22-debug_breakpoint_part1.md) * [9.2.23 tinydbg> breakpoint - part2: 添加断点+执行到断点](9-develop-sym-debugger/2-核心调试逻辑/23-debug_breakpoint_part2.md) * [9.2.24 tinydbg> breakpoint - part3: 条件断点+执行到断点](9-develop-sym-debugger/2-核心调试逻辑/24-debug_breakpoint_part3.md) * [9.2.25 tinydbg> breakpoint - part4: 其他断点相关命令](9-develop-sym-debugger/2-核心调试逻辑/25-debug_breakpoint_part4.md) * [9.2.26 tinydbg> trace](9-develop-sym-debugger/2-核心调试逻辑/26-debug_trace.md) * [9.2.27 tinydbg> watch](9-develop-sym-debugger/2-核心调试逻辑/27-debug_watch.md) * [9.2.28 tinydbg> continue](9-develop-sym-debugger/2-核心调试逻辑/28-debug_continue.md) * [9.2.29 tinydbg> next](9-develop-sym-debugger/2-核心调试逻辑/29-debug_next.md) * [9.2.30 tinydbg> step](9-develop-sym-debugger/2-核心调试逻辑/30-debug_step.md) * [9.2.31 tinydbg> pmem](9-develop-sym-debugger/2-核心调试逻辑/31-debug_pmem.md) * [9.2.32 tinydbg> regs](9-develop-sym-debugger/2-核心调试逻辑/32-debug_regs.md) * [9.2.33 tinydbg> funcs](9-develop-sym-debugger/2-核心调试逻辑/33-debug_funcs.md) * [9.2.34 tinydbg> vars](9-develop-sym-debugger/2-核心调试逻辑/34-debug_vars.md) * [9.2.35 tinydbg> bt](9-develop-sym-debugger/2-核心调试逻辑/35-debug_bt.md) * [9.2.100 how ListFunctions work](9-develop-sym-debugger/2-核心调试逻辑/100-how_listfunctions_work.md) * [9.2.100 tinydbg> goroutines](9-develop-sym-debugger/2-核心调试逻辑/100-debug_goroutines.md) * [9.2.100 跟踪多线程程序](9-develop-sym-debugger/2-核心调试逻辑/100-multi-threads.md) * [9.3 高级功能扩展](9-develop-sym-debugger/3-高级功能扩展/README.md) * [9.3.100 集成starlark](9-develop-sym-debugger/3-高级功能扩展/100-howto_integrate_starlark.md) * [9.3.101 基于eBPF跟踪](9-develop-sym-debugger/3-高级功能扩展/101-howto_tracing_via_ebpf.md) * [9.3.102 实现语法高亮](9-develop-sym-debugger/3-高级功能扩展/102-howto_syntax_highlight.md) * [9.3.103 实现分页输出](9-develop-sym-debugger/3-高级功能扩展/103-howto_paging_output.md) * [9.3.104 读取分离存储的DWARF数据](9-develop-sym-debugger/3-高级功能扩展/104-howto_read_separate_dwarfdata.md) * [9.3.105 自动推测源文件路径映射](9-develop-sym-debugger/3-高级功能扩展/105-howto_guess_substitutepath.md) * [9.3.106 实现目标进程IO重定向](9-develop-sym-debugger/3-高级功能扩展/106-howto_redirect_target_io.md) * [9.3.107 支持用户偏好配置](9-develop-sym-debugger/3-高级功能扩展/107-howto_customize_tinydbg.md) * [9.3.108 支持多客户端调试模式](9-develop-sym-debugger/3-高级功能扩展/108-howto_accept_multiclient.md) * [9.3.109 支持调试活动记录本](9-develop-sym-debugger/3-高级功能扩展/109-howto_transcript_debugging.md) * [9.3.110 如何实现确定性调试](9-develop-sym-debugger/3-高级功能扩展/110-howto_debug_deterministically.md) * [10 主流调试技术解析](10-extras/README.md) * [10.1 调试器发展历程](10-extras/1-development-of-debugger.md) * [10.2 日志系统展历程](10-extras/2-development-of-logging.md) * [10.3 指标系统发展历程](10-extras/3-development-of-metrics.md) * [10.4 跟踪系统发展历程](10-extras/4-development-of-tracing.md) * [10.5 Bisect发展历程](10-extras/5-development-of-bisect.md) * [10.6 eBPF发展历程](10-extras/6-development-of-ebpf.md) * [10.7 录制重放发展历程](10-extras/7-development-of-replay.md) * [10.8 分布式调试发展历程](10-extras/8-development-of-debug-dsys.md) * [10.9 AI Agent自动化调试](10-extras/9-ai-assisted-debugging.md) * [11 致谢](11-thanks/README.md) * [12 附录](12-appendix/README.md) * [12.1 go程序启动流程](12-appendix/1-go-programme-start.md) * [12.2 自动跟踪新线程](12-appendix/2-auto-trace-cloned-threads.md) * [12.3 git bisect](12-appendix/3-git-bisect.md) * [12.4 vscode extension bisect](12-appendix/4-vscode-extension-bisect.md) * [12.10 编译工具链/compile](12-appendix/80-go-tool-compile.md) * [12.11 编译工具链/asm](12-appendix/81-go-tool-asm.md) * [12.12 编译工具链/link](12-appendix/82-go-tool-link.md) * [12.13 More: GNU build-id+gobuildid](12-appendix/90-why-buildid-loaded.md) * [12.14 More: 语法分析vs语义分析区别](12-appendix/91-syntax-and-semantic-analysis.md) * [12.15 More: 为什么GDB使用符号表+DWARF](12-appendix/92-why-gdb-uses-symtab.md) ================================================ FILE: book/book.json ================================================ { "plugins": [ "numbered-headings", "github", "intopic-toc", "splitter", "-lunr", "-search", "search-pro" ], "pluginsConfig": { "intopic-toc": { "selector": ".markdown-section h1, .markdown-section h2, .markdown-section h3, .markdown-section h4, .markdown-section h5, .markdown-section h6", "visible": true, "mode": "nested", "isCollapsed": false, "isScrollspyActive": false, "label": "本文目录" }, "github": { "url": "https://github.com/hitzhangjie/golang-debugger-book" }, "disqus": { "shortName": "gitbookuse" } } } ================================================ FILE: changelog.md ================================================ ## 项目进度 - ~ - 2018.11.30 完成Linux平台调试器开发 - ~ - 2018.12.31 完成调试器开发文档撰写 - ~ - 2019.01.31 完成《从0开发go调试器》相关章节 - ~ - 2019.02.28 完成《从调试器看go类型系统》相关章节 - ~ - 2019.03.31 完成《从调试器看go调度系统》相关章节 - ~ - 2019.04.30 通读全文,理顺各个章节内容 - ~ - 2019.05.31 与出版社进行沟通,决定是否出版 - 能出版就出版 - 出版不了就做成免费的电子书分发 >备注:项目已经成功延期了一年,so sad... 重新制定下项目开发计划 - ~ - 2019.10.06~2019.10.13 回顾下调试标准Dwarf v4的内容 - ~ - 2019.10.14~2019.10.20 基于go v1.12.6+linux,开发指令级调试器 - ~ - 2019.10.21~2019.10.27 熟练掌握go标准库中debug、elf相关操作 - ~ - 2019.10.28~2019.11.03 基于go v1.12.6+linux,开发符号级调试器 - 实现ELF的解析 - 实现.debug_info的解析 - 实现.debug_line的解析 - ... > 备注:2019年完成了DWARF v4标准的学习、解读、翻译,并完成了本书一至五章内容的撰写。 - 2020.11.1 ~ - 2020.11.30 完成了第六章、第七章,并提供了demo,实现了一个相对完整的指令集调试器。符号级调试器部分dwarf解析已经解释完成,待提供demo,完成后迅速可转入符号级调试器开发。 > 2020年在此基础上开始进行实际的开发工作。目前已经实现了一个相对完整的指令级调试器,目前正在编写DWARF操作相关demo,完成后立即开始符号级调试器开发工作。 - 2021.1.1 ~ 2021.1.3 完成第8章dwarf demo相关工作 - 2021.1.4 ~ 2021.1.8 完成符号级调试器的基础操作 - 2021.1.9 ~ 2021.1.15 和dlv调试器对齐下能力 ================================================ FILE: contributing.md ================================================ # Contributing Guide # introduction # golang-debugger This project aims at describing how to develop a (symbolic) golang debugger, like gdb or dlv. Why we do this? The purpose of this project isn't only finishing developping a golang debugger, it focuses on how-to develop rather than the final implemention. The contents covered may include CPU, CPU Instruction Set, Operating System, ELF, Compiler and Linker, Debugging Information Standards, Debugger, Language Design Internals, etc. In a word, we want to use developping a symbolic golang debugger as a chance to introduce how CPU, OS, ELF, Compiler and Linker, Debugger coordinate with each others. Besides, we can also inspect the design internals of go programming language. Thanks to the contributors of gdb, delve, DWARF, etc. I learned a lot from them. Now I want to share the knowledge. # contribution Our work will be around with github.com: 1. come up with ISSUE 2. discuss the ISSUE 3. fork the project, develop, test, fix, create a PR 4. review PR and merge 5. tag and release # welcome Welcome you to contribute to this book and share them. ================================================ FILE: contributing.zh_CN.md ================================================ # Contributing Guide # 写在开头 # golang-debugger 该项目“**golang debugger**”,是一款面向go语言的调试器,现在业界已经有针对go语言的调试器了,如gdb、dlv等等,那么为什么还要从头再开发一款调试器呢? 建立项目的初衷并不是只为了开发一款调试器,而是希望从调试器为切入点,将作者多年以来掌握的知识进行融会贯通,这里的内容涉及go语言本身(类型系统、协程调度)、编译器与调试器的协作(DWARF)、操作系统内核(虚拟内存、任务调度、系统调用、指令patch)以及处理器相关指令等诸多内容。 简言之,就是希望能从开发一个go语言调试器作为入口切入,帮助初学者快速上手go语言开发,也在循序渐进、拔高过程中慢慢体会操作系统、编译器、调试器、处理器之间的协作过程、加深对计算机系统全局的认识。由于本人水平有限,不可能完全从0开始自研一款调试器,特别是针对go这样一门快速演进中的语言,所以选择了参考开源社区中某些已有的调试器实现gdb、delve作为参考,结合相关规范、标准慢慢钻研的方式。 希望该项目完结之时,能将其沉淀为知识分享,如果能以书籍的方式最终出版,那再好不过了,也算是我磨练心性、自我救赎的一种方式,也希望能帮助到对此感兴趣、未曾涉足的同僚。 # 意见反馈 请邮件联系 `hit.zhangjie@gmail.com`,标题中请注明来意`golang debugger交流`。 # 贡献方式 我们的基本协作方式基于github展开: 1. 提出ISSUE - 提ISSUE之前,请务必先搜索是否已经存在近似描述的ISSUE,如果已经存在可以在ISSUE下补充描述,请避免重复提相同ISSUE的问题; - 新建ISSUE的时候,请根据问题类型选择ISSUE模板,如新特性、Bug、优化建议等,并根据模板内容填写信息,方便我们高效地进行协作; 2. 解决ISSUE - 首先需要Fork主仓库到自己的仓库中,并定期保持代码库更新; - 当准备解决一个ISSUE前请务必确认是否ISSUE是否被开发者认领,考虑到大家可能提出不同的解决方案,方案探讨可以在同一个ISSUE下进行,避免重复处理同一个问题; - 在决定将内容推送到本仓库时,请先拉取本仓库代码进行合并,并处理好冲突,同时确保相关文档与代码保持同步; - 然后再将分支PR到主仓库的master分支,其中PR需要包含以下基本信息: - 标题:本次PR的目的(做了什么工作,修复了什么问题); - 内容:如果必要的话,请给出对修复问题的描述,力求简短清晰; 3. 项目维护人员在PR评论区进行评论,如果发现PR中有什么问题,请直接指出并尽量给出修正的方式,或者也可以直接进行修改; 4. 提出该PR的人根据评论修正内容,然后将修改后的内容Merge到master分支中; 5. 项目维护人员审核通过,合并PR到主仓库中; # 注意事项 - 每次Pull Request应只解决一个问题,这样方便进行修改; - 每次Pull Request应确保已经同步过主仓库代码、解决冲突,并自己在本地测试通过; - 根据贡献者的活跃程度、对框架贡献熟悉程度,我们会筛选一部分开发者加入项目维护人员列表中来; # 欢迎加入 希望热爱开源并对debugger、dwarf、golang等相关内容感兴趣的同学加入进来,共同参与该项目的建设,让“开发”变得更简单美好,让知识钻研、分享变得更加纯粹。 ================================================ FILE: deploy.sh ================================================ #!/bin/bash -e # repository to fetch book content deploy=https://github.com/hitzhangjie/debugger101.io # build the book for Chinese version book="book" # create a temporary folder and be sure to delete them when exit tmpdir=$(mktemp -d) trap 'sudo rm -rf "$tmpdir"' EXIT builddir=$(mktemp -d) trap 'sudo rm -rf "$builddir"' EXIT # build the book and publish to github printf "\033[0;32mDeploying updates to GitHub...\033[0m\n" git clone --depth 1 $deploy $tmpdir # deploy by `gitbook-cli` image docker run --name gitbook --rm \ -v ${PWD}:/root/gitbook \ -v $builddir:$builddir \ hitzhangjie/gitbook-cli:latest \ bash -c "cd $book && gitbook install && cd - && gitbook build $book $builddir" # deploy by installed `gitbook-cli` #gitbook build $book $builddir # go to publishdir and commit cp -rf $builddir/* $tmpdir/ # Commit changes. cd $tmpdir git add . msg="rebuilding site $(date)" if [ -n "$*" ]; then msg="$*" fi git commit -m "$msg" # Push source and build repos. git push -f -u origin master cd -