调试汇编感觉就像时光旅行——现代工具确实帮了忙,但逐行解析底层指令的乐趣依然像魔法一样迷人。
记得90年代用我的Amiga 1200写汇编代码时,我还挺开心的。那时我并不是职业做这个,只是一个十几岁的孩子。精心编写的代码经常无法如预期般运行,所以我得调试找出哪里出错。这跟现在用高级编程语言时的感觉差不多。
现在开发者还会调试汇编代码吗?看看现在可用的工具,我可以肯定地说“是的”——但我不是不清楚它是一个小众需求。
今天试试EDB(https://github.com/eteran/edb-debugger),一个可以在我的Linux机器上调试x86–64代码的调试器。让我们玩得尽兴!
有两种方式可以安装调试器。第一种方式很简单,因为大多数Linux发行版都已经包含了调试器。我使用我的软件包管理器安装调试器。
$ sudo apt update
$ sudo apt install edb-debugger
不幸的是,Ubuntu包并不是最新版本,所以我决定从源代码编译EDB。这个过程看上去很简单,但缺少的依赖很快让我的主机操作系统变得一团糟。以下是我如何完成的:
以下是我是如何完成的:
$ git clone git@github.com:eteran/edb-debugger.git # 克隆代码仓库
$ cd edb-debugger # 切换到 edb-debugger 目录
$ mkdir build # 创建一个名为 build 的文件夹
$ cd build # 切换到 build 目录
$ cmake .. # 使用 cmake 配置项目
$ make # 构建项目
$ sudo make install # 使用 sudo 权限安装项目
虽然我已经解决了问题,但我不确定最新版本中的小改进是否值得费劲。
我通常都在Docker容器里运行所有的开发工具,但这次我直接在我的主机上安装了EDB工具。为什么呢?带有图形界面的调试器能否在容器里运行,并且在Linux主机上显示界面?现在就来试试看。
结果是,准备这样的容器相对简单来说——或者至少对于每天处理容器的人来说是相对简单的。我将基于 ubuntu:24.04
来制作新的容器,使其与我当前的主机一致。以下是初稿:
# 从 Microsoft 容器镜像仓库拉取基础 Ubuntu 24.04 镜像
FROM mcr.microsoft.com/vscode/devcontainers/base:ubuntu-24.04
# 设置用户为 vscode
USER vscode
# 更新和升级系统包,并安装必要的开发工具
RUN sudo apt-get update \
&& sudo apt-get upgrade -y \
&& sudo apt-get install -y \
xxd wget tar make cmake libwayland-client0 \
xdg-utils libgvc6 pkg-config git build-essential \
libcapstone-dev qtbase5-dev libqt5xmlpatterns5-dev \
libqt5svg5-dev qttools5-dev \
&& sudo rm -rf /var/lib/apt/lists/*
# 下载并安装 edb-debugger
RUN cd /tmp \
&& wget https://github.com/eteran/edb-debugger/releases/download/1.5.0/edb-debugger-1.5.0.tgz \
&& tar -xvzf edb-debugger-1.5.0.tgz \
&& cd edb-debugger \
&& mkdir build \
&& cd build \
&& cmake .. \
&& make \
&& sudo make install
# 下载并安装 NASM
RUN cd /tmp \
&& wget https://www.nasm.us/pub/nasm/releasebuilds/2.16.03/nasm-2.16.03.tar.gz \
&& tar -xvzf nasm-2.16.03.tar.gz \
&& cd nasm-2.16.03 \
&& ./configure \
&& make \
&& sudo make install \
&& nasm --version
这个过程很简单:
- 安装所需的构建工具。
- 从GitHub发布的源代码构建EDB。
- 从官方提供的源代码构建NASM。
很简单,对吧?但,它会起作用吗?暂时还不行。首先,我们需要配置vscode以特权模式运行,并且我们还需要一些主机和容器之间的映射关系:
{
"name": "admac-dev",
"build": {
"dockerfile": "Dockerfile",
"context": ".."
},
"runArgs": [
"--privileged",
"--env=DISPLAY=${env:DISPLAY}", // 环境变量DISPLAY设置为当前DISPLAY值
"--volume=/tmp/.X11-unix:/tmp/.X11-unix" // 映射X11的unix套接字文件
]
}
这里的关键在于使用特权权限运行,并重定向X11文件套接字。那么现在可以运行了吗?还不行呢。我们还需要让主机的X11服务器接受容器的连接请求。这只需要一行代码就能搞定。
$ xhost +local:*
这命令允许本地机器上的X服务器进行网络访问。
现在呢,所有事情都应该会都能正常运行了。
现在是用汇编写代码的时候了。我们可以编写一个非常简单的程序,它什么也不做。这听起来很有趣,对吧?我们可以使用以下几行来实现:
; 汇编代码示例
; 这里应该放入实际的汇编代码,例如优雅地退出程序的操作
(注释部分已被删除,以符合原文要求。)
section .text
global _start
_start:
; 设置rax寄存器的值为60
mov rax, 60
; 将rdi寄存器清零
xor rdi, rdi
; 执行系统调用,用于正常退出程序
syscall
它会在 x86-64 Linux 上运行。我可以手动编译它,但我不想重复这个过程。我将创建一个 Makefile 来自动编译所有的 NASM 和 C 文件,并把它们链接成一个可执行文件:
LD=ld
CC=gcc
NASM=nasm
CFLAGS=-Wall -Wextra -O2 -g -fno-pic
NASMFLAGS=-f elf64
LDFLAGS=-z noexecstack -nostdlib -e _start
OUTPUT=bin/admac
SRCDIR=src
OBJDIR=obj
SRCS_C=$(wildcard $(SRCDIR)/*.c)
SRCS_ASM=$(wildcard $(SRCDIR)/*.s)
OBJS=$(patsubst $(SRCDIR)/%.c, $(OBJDIR)/%.o, $(SRCS_C)) \
$(patsubst $(SRCDIR)/%.s, $(OBJDIR)/%.o, $(SRCS_ASM))
# 编译目标文件
build: $(OUTPUT)
# 运行生成的程序
run: build
@$(OUTPUT)
# 使用edb调试器运行程序
debug: build
@edb --run $(OUTPUT) 2> /dev/null
# 将目标文件链接生成输出文件
$(OUTPUT): $(OBJS)
# 确保输出目录存在
@mkdir -p bin
$(LD) $(LDFLAGS) -o $@ $^
$(OBJDIR)/%.o: $(SRCDIR)/%.c
@mkdir -p $(OBJDIR)
$(CC) $(CFLAGS) -c $< -o $@
$(OBJDIR)/%.o: $(SRCDIR)/%.s
@mkdir -p $(OBJDIR)
$(NASM) $(NASMFLAGS) $< -o $@
# 清除生成的目标文件和输出文件
clean:
@rm -rf $(OBJDIR) $(OUTPUT)
.PHONY: build run debug clean
当我运行 make run
时,目前没有任何明显的反应。但运行 make debug
则非常有意思。它会在我的容器内环境内启动一个调试会话,但调试窗口却显示在我的主机屏幕上。我们来运行它吧。
EDB 调试
它显示了四个窗格。最大的一个显示了反汇编的代码。右边我们可以查看所有寄存器的状态,包括AVX2。在底部,我们可以查看内存和栈的内容。看起来简直完美。几乎就像我90年代用过的MonAm调试器一样。
https://developer-blog.net/amiga-assembler-programmieren/ 编写Amiga汇编程序
我真的被它的简洁性所震撼。我可以看到我可以“单步步入”,“单步跃过”,甚至可以设置断点。这样已经足够让我玩得开心了。
我想EDB是如何处理代码分支和函数的呢?咱们写点更多的代码试试看。
段 .text
声明外部 stdout_print
全局声明 _start
_start:
lea 指令 rdi, [rel msg_hello]
调用 stdout_print()
mov 指令 rax, 60
执行 xor rdi, rdi
系统调用
段 .rodata
msg_hello:
dq 14 ; 定义一个双字数据
db "Hello, World!", 10 ; 定义一个字节数据
; .text 段用于存放可执行的机器指令
; .rodata 段用于存放只读数据
上面的代码会调用一个这样的函数来输出文本。该函数可能类似如下所示:
例如:
; 错误码
ERR_ZERO_WRITE equ -33 ; 没有写入字节时的错误
ERR_BUFFER_OVFLW equ -35 ; 缓冲区溢出错误
ERR_STACK_ALIGN equ -36 ; 栈未对齐错误
section .text
global stdout_print
; 从字符串中打印指定数量的字符到标准输出
; rdi - 指向[length (8 bytes), 字符串 (ascii, 非null终止)]的指针
; rax - 如果没有错误返回0,否则返回负值表示错误码
stdout_print:
lea rax, [rsp + 8] ; 计算 RSP + 8
test rax, 15 ; 检查栈对齐
jnz .unaligned ; 栈不对齐时跳转到.unaligned作为错误
mov rdx, [rdi] ; 加载字节计数
lea rsi, [rdi + 8] ; 加载字符串指针
mov rdi, 1 ; 标准输出描述符
test rdx, rdx ; 检查要写入的字节数
jz .completed ; 如果为零则直接完成,不执行系统调用
.loop:
mov rax, 1 ; sys_write 系统调用
syscall ; 执行系统调用
test rax, rax ; 比较系统调用结果中的 RAX 与 0
jz .zero ; 如果没有进展则跳转到 .zero
js .end ; 如果内核失败则跳转到 .end
sub rdx, rax ; 减少剩余字节数
add rsi, rax ; 移动缓冲区指针
test rdx, rdx ; 检查是否有剩余字节数
jnz .loop ; 如果所有字节已写则不跳转
.completed:
xor rax, rax ; 没有错误
jmp .end
.unaligned:
mov rax, ERR_STACK_ALIGN ; 设置错误表示栈未对齐
jmp .end
.zero:
mov rax, ERR_ZERO_WRITE ; 设置错误表示零写入
jmp .end
.end:
ret ; 返回 RAX 中的错误码
这个函数稍微有点复杂,因为它处理了一些特殊情况。目的是检查所有分支在反汇编器中的显示情况如何。我们可以再试一次运行。
EDB 调试会话
我们现在可以看到更多功能。其中之一是箭头指示代码可能跳转的地方。我可以看到,在执行这一行之前,当前的跳转将不会被执行。很好。这个视图还可以可视化各种寄存器指向的位置。这里,RSP保存了该函数的返回地址(在调用时压入了栈中),并且很好地可视化了。做得不错,EDB团队!
在90年代,调试汇编程序是一项考验耐心的任务——每次崩溃后都得重启,有时候还得完全重写。今天,我们有了可视化调试器,可以在实时环境中显示一切,但核心技能依然没有改变:理解CPU在做什么及原因。不论是Amiga上的MonAm还是Linux容器里的EDB,调试仍然是一门科学和艺术的结合。也许如今使用汇编语言的开发者少了,但对于那些仍在从事这项工作的人来说,控制机器的魅力依然和几十年前一样让人兴奋。
共同学习,写下你的评论
暂无评论
作者其他优质文章