工具链与调试环境 (Toolchain & Debug Environment)


章节概述

汇编开发的工具链比 C 语言更底层——没有 libc、没有 ELF 装载器帮你处理一切。本章搭建 NASM 汇编器 + ld 链接器 + GDB 调试 + QEMU 虚拟裸机的完整环境,这是后续所有硬件操作章节的前置基础。如果你从 C 语言教程转过来,会发现这和 gcc && ./a.out 的工作方式有本质不同——你需要亲自指定输出格式、入口符号和段布局。

核心理念:工具链搭建的目的不只是”能用”,而是”看清楚每一步发生了什么”。因此本章同时介绍 objdump 反汇编和 GDB layout asm——让你从第一天起就能观察汇编指令如何在 CPU 里跑。


第一节:NASM 汇编器


1.1 安装

# Linux (Debian/Ubuntu)
sudo apt install nasm binutils gdb qemu-system-x86
 
# Linux (Arch)
sudo pacman -S nasm binutils gdb qemu-base
 
# macOS
brew install nasm

验证安装:

nasm --version # NASM version 2.x
ld --version # GNU ld (GNU Binutils)
gdb --version
qemu-system-x86_64 --version

1.2 第一个 NASM 程序:裸机 Hello World

先看一个最简 NASM 程序(不依赖任何 C 库):

; hello.asm — 裸机 Hello World (x86-64 Linux)
; 编译: nasm -f elf64 hello.asm && ld hello.o -o hello && ./hello
 
section .data
 msg db 'Hello from raw assembly!', 0x0a ; 0x0a = '\n'
 len equ $ - msg
 
section .text
 global _start
 
_start:
 mov rax, 1 ; syscall number: sys_write
 mov rdi, 1 ; fd: stdout
 mov rsi, msg ; buf
 mov rdx, len ; count
 syscall ; write(1, msg, len)
 
 mov rax, 60 ; syscall number: sys_exit
 xor rdi, rdi ; exit code: 0
 syscall ; exit(0)

编译运行:

nasm -f elf64 hello.asm # 汇编: .asm → .o (ELF64 目标文件)
ld hello.o -o hello # 链接: .o → 可执行文件
./hello # Hello from raw assembly!

一步步解析:

  1. -f elf64:指定输出格式为 ELF64(Linux x86-64)。NASM 支持多种格式(elf32macho64bin 等)
  2. 没有 #include、没有 main、没有 libc——起点就是 _start
  3. syscall 直接调用 Linux 内核——这是汇编的力量:不需要任何中间层就能操作系统

对比 C 语言:gcc hello.c 背后做了预处理→编译→汇编→链接→包含 libc 启动代码(_start__libc_start_mainmain)→动态链接。NASM 程序跳过了这一切,你亲手控制每一个字节。

1.3 NASM 常用命令选项

选项作用
-f elf64输出 ELF64 格式(Linux x86-64)
-f bin输出纯二进制(用于 bootloader)
-f elf32输出 ELF32 格式(x86 32位)
-o file.o指定输出文件名
-g生成调试信息(DWARF 格式)
-F dwarf显式指定调试格式为 DWARF
-l list.lst生成列表文件(地址+机器码+源码对照)
-D SYMBOL=value定义预处理符号
-I dir添加 include 搜索路径

带调试信息的编译:

nasm -f elf64 -g -F dwarf hello.asm -o hello.o
ld hello.o -o hello
gdb ./hello

小节练习


第二节:ld 链接与 ELF 结构


2.1 ld 链接器基础

# 基本链接
ld hello.o -o hello
 
# 多目标文件链接
ld main.o io.o utils.o -o program
 
# 指定入口符号(默认为 _start)
ld -e my_entry main.o -o program
 
# 动态链接 libc
ld -dynamic-linker /lib64/ld-linux-x86-64.so.2 \
 /usr/lib/x86_64-linux-gnu/crt1.o \
 /usr/lib/x86_64-linux-gnu/crti.o \
 hello.o -lc \
 /usr/lib/x86_64-linux-gnu/crtn.o \
 -o hello

动态链接 libc 的复杂命令解释了为什么 C 程序员习惯 gcc 而非直接 ld

2.2 链接脚本(Linker Script)

ld 默认使用内置链接脚本。对于裸机程序(后续章节),你需要自定义:

/* link.ld — 裸机程序的链接脚本 */
SECTIONS {
 . = 1M; /* 加载到 1MB 地址处 */
 .text : { *(.text) } /* 代码段 */
 .data : { *(.data) } /* 数据段 */
 .bss : { *(.bss) } /* 未初始化数据 */
}

使用自定义脚本:

ld -T link.ld kernel.o -o kernel.bin

小节练习


第三节:objdump 与 readelf 反汇编


3.1 objdump:窥视程序的真实面目

# 反汇编所有代码段
objdump -d hello
 
# 显示完整机器码+源码混合
objdump -d -M intel -S hello
 
# 查看段(section)信息
objdump -h hello
 
# 查看符号表
objdump -t hello

典型输出:

$ objdump -d -M intel hello | grep -A8 '<_start>'
<_start>:
 401000: b8 01 00 00 00 mov eax, 0x1
 401005: bf 01 00 00 00 mov edi, 0x1
 40100a: 48 be 00 20 40 00 00 movabs rsi, 0x402000
 401014: ba 18 00 00 00 mov edx, 0x18
 401019: 0f 05 syscall
 40101b: b8 3c 00 00 00 mov eax, 0x3c
 401020: 31 ff xor edi, edi
 401022: 0f 05 syscall

注意:mov eax, 0x1 的高 32 位自动清零(x86-64 特性),NASM 源码中写 mov rax, 1 但汇编器聪明地生成了更短的 mov eax 形式。

3.2 readelf:解析 ELF 结构

# 查看 ELF 文件头
readelf -h hello
 
# 查看段头表(Section Headers)
readelf -S hello
 
# 查看程序头表(Program Headers)
readelf -l hello

3.3 NASM 列表文件:源码→机器码逐行对应

nasm -f elf64 -l hello.lst hello.asm
cat hello.lst
1 section .data
2 00000000 48656C6C- msg db 'Hello from raw assembly!', 0x0a
2 00000008 6F206672-
...
9 section .text
10 _start:
11 00000000 B801000000 mov rax, 1
12 00000005 BF01000000 mov rdi, 1

列表文件是最直观的”汇编→机器码”对照表。每行包含:行号、偏移地址、十六进制机器码、源码。

小节练习


第四节:GDB 汇编级调试


4.1 编译带调试信息的程序

nasm -f elf64 -g -F dwarf hello.asm -o hello.o
ld hello.o -o hello
gdb ./hello

4.2 核心命令

# 汇编级单步 (si = step instruction)
(gdb) si
 
# 汇编级跳过 (ni = next instruction)
(gdb) ni
 
# 显示汇编布局 (TUI 模式)
(gdb) layout asm
(gdb) layout regs # 寄存器 + 汇编窗口
 
# 打印寄存器
(gdb) info registers
(gdb) p $rax
(gdb) p /x $rax # 十六进制显示
 
# 查看内存
(gdb) x /10i $rip # 当前地址后 10 条指令
(gdb) x /16bx msg # 以十六进制字节查看 msg 标签处的内存
(gdb) x /s msg # 以字符串查看 msg
 
# 断点
(gdb) break *_start
(gdb) break *0x401000
 
# 反汇编当前函数
(gdb) disassemble _start

GDB 汇编调试工作流:

$ gdb ./hello
(gdb) break *_start
(gdb) run
(gdb) layout regs # 打开寄存器窗口
(gdb) si # 单步执行一条汇编指令
(gdb) info registers rax rdi rsi rdx
(gdb) si # 继续单步…
(gdb) continue

4.3 对比 C 语言调试

操作GDB for CGDB for ASM
单步n (next, 源代码行)si (step instruction)
查看变量p var_namex /4x $rsp+8 (查栈)
函数栈btbt + x /16gx $rsp (手动分析)
反汇编disas funcdisas _start (无函数概念)

小节练习


第五节:QEMU 虚拟裸机环境


5.1 为什么需要 QEMU

在 Linux 用户态下,多数”直接操作硬件”的指令被禁止:

  • in/out(端口 I/O)→ 触发段错误
  • hlt(停机)→ 段错误
  • cli/sti(开关中断)→ 权限不足
  • MMIO 访问 → 虚拟地址空间不映射

QEMU 提供一个完整的虚拟 x86 计算机,你可以从 BIOS/UEFI 启动到第一条指令,没有任何限制。

5.2 QEMU 基础命令

# 从裸机镜像启动
qemu-system-x86_64 -drive file=kernel.bin,format=raw
 
# 标准配置(无图形输出,串口重定向)
qemu-system-x86_64 \
 -drive file=kernel.bin,format=raw \
 -nographic \
 -serial stdio \
 -monitor telnet:127.0.0.1:4444,server,nowait
 
# 带 GDB 远程调试
qemu-system-x86_64 \
 -drive file=kernel.bin,format=raw \
 -nographic \
 -s -S # -s: 在 1234 端口开 GDB 服务, -S: 启动时暂停

然后在另一个终端连接:

gdb
(gdb) target remote :1234
(gdb) layout asm

5.3 QEMU Monitor:查看硬件状态

QEMU Monitor 让你在虚拟机运行时检查 CPU 状态:

# 在 QEMU Monitor 中(Ctrl-A C 进入):
(qemu) info registers # 所有 CPU 寄存器
(qemu) xp /8i 0x7c00 # 查看物理地址 0x7c00 的指令
(qemu) xp /16bx 0xb8000 # 查看 VGA 显存
(qemu) info pic # 中断控制器状态
(qemu) info ioapic # I/O APIC 状态
(qemu) stop # 暂停 CPU
(qemu) cont # 恢复执行

QEMU Monitor 是硬件调试的最强工具——你能”停机”后检查 CPU 寄存器、内存和外设寄存器,这在真机上做不到。

5.4 典型工作流

# 终端 1: 启动 QEMU(挂起等待 GDB)
qemu-system-x86_64 -drive file=kernel.bin,format=raw -nographic -s -S
 
# 终端 2: GDB 连接并调试
gdb -ex "target remote :1234" -ex "layout asm"
# (gdb) break *0x7c00
# (gdb) continue
# (gdb) si

小节练习

判断题 1

在 Linux 用户态程序中执行 in al, 0x60(读键盘端口)不会报错。 ( )

  • 正确

  • 错误


章节测试

一、判断题

判断题 1

NASM 编译的程序从 _start 入口开始执行,不依赖任何 C 标准库。 ( )

  • 正确

  • 错误

判断题 2

nasm -f elf64 生成的是纯二进制文件(flat binary)。 ( )

  • 正确

  • 错误

判断题 3

objdump -d 只反汇编 .text 段,不显示数据段。 ( )

  • 正确

  • 错误

判断题 4

GDB 的 si 命令在遇到 call 指令时会进入被调用函数内部单步。 ( )

  • 正确

  • 错误

判断题 5

QEMU 的 -s 选项在端口 12345 上开启 GDB 调试服务。 ( )

  • 正确

  • 错误

动手练习题

练习题 1:亲手编译调试

难度: 简单

编写一个 NASM 程序,计算 1 + 2 + ... + 100,结果存入 rax,然后用 sys_exit 以结果为退出码退出。用 objdump -d 查看生成的机器码,用 echo $? 验证结果(应为 5050)。

练习题 2:GDB 单步跟踪

难度: 简单

对练习题 1 的程序,使用 GDB 进行汇编级单步调试:

  1. break *_start
  2. layout regs
  3. 逐条 si,观察 rax 的变化
  4. syscall 之前查看最终值

练习题 3:NASM 列表文件对照

难度: 简单

使用 nasm -l 生成列表文件,对照每一行的机器码(十六进制)和助记符源码。找出 mov rax, 1 对应的实际机器码字节。

练习题 4:QEMU 裸机启动

难度: 简单

使用 NASM -f bin 编译一个极简裸机程序(让 CPU 进入死循环 jmp $),用 QEMU 加载并在 Monitor 中查看 info registers。参考:

; bare.asm
bits 16
hlt: jmp hlt
nasm -f bin bare.asm -o bare.bin
qemu-system-x86_64 -drive file=bare.bin,format=raw -nographic -monitor stdio

练习

以下题目从汇编/底层视角训练相关能力:

题号题目链接涉及知识点
29两数相除https://leetcode.cn/problems/divide-two-integers/位运算、C编译链理解
50Pow(x, n)https://leetcode.cn/problems/powx-n/递归、编译优化
69x的平方根https://leetcode.cn/problems/sqrtx/整数运算、底层算术