一文打通 Linux 与 Windows 下的 NASM 汇编编程:从最底层系统调用、libc 接口,到 GUI 程序与调用约定详解,涵盖 32 位与 64 位写法,并剖析构建、链接、重定位背后的机制
🧠 前言
nasm 是为 模块化和可移植性 而设计的 80x86 汇编器。它语法简洁,类似 Intel 风格,并支持丰富的输出格式,如:
- ELF(Linux 下常用)
- Win32/Win64(适配 Windows PE 格式)
- Binary(适合写 bootloader 或 shellcode)
🐧 Linux 编程
🚀 快速开始:最简 Exit 示例
mov eax, 1 ; sys_exit
mov ebx, 42 ; exit code
int 0x80
nasm -f elf32 exit.asm
ld -m elf_i386 -o exit exit.o
./exit
echo $? # => 输出 42
32 位 Linux:裸系统调用
;=============================================
; 数据段 - 存储常量和静态数据
;=============================================
section .data
msg db "hello, world", 10 ; 字符串 + 换行符(ASCII 10)
len equ $-msg ; 计算字符串长度
;=============================================
; 代码段 - 程序执行部分
;=============================================
section .text
global _start ; 声明入口点为_start
_start:
;-----------------------------------------
; 系统调用: sys_write(4)
; 参数:
; ebx = 文件描述符 (1 = stdout)
; ecx = 字符串指针
; edx = 字符串长度
;-----------------------------------------
mov edx, len ; 设置字符串长度
mov ecx, msg ; 设置字符串地址
mov ebx, 1 ; 标准输出文件描述符
mov eax, 4 ; sys_write系统调用号
int 0x80 ; 触发系统调用
;-----------------------------------------
; 系统调用: sys_exit(1)
; 参数:
; ebx = 退出状态码
;-----------------------------------------
mov ebx, 0 ; 返回状态码0(成功)
mov eax, 1 ; sys_exit系统调用号
int 0x80 ; 触发系统调用
🔧 编译 & 运行
nasm -f elf32 hello.asm -o hello.o
ld -m elf_i386 hello.o -o hello
./hello
也可以用 gcc 替代 ld:
gcc -m32 -nostdlib hello.o -o hello -no-pie
-m32
:生成32位代码-no-pie
:禁用位置无关可执行文件(适合简单汇编程序)
sudo apt install gcc-i686-linux-gnu
i686-linux-gnu-gcc -m32 -nostdlib hello.o -o hello -no-pie
或者
i686-linux-gnu-ld -m elf_i386 -o hello hello.o
32 位 Linux:调用 libc (puts
)
section .note.GNU-stack noalloc noexec nowrite
section .text
global main
extern puts
main:
push message ; 参数压入栈顶
call puts ; 调用 puts
add esp, 4 ; 清理栈上的参数
mov eax, 0 ; 返回值 0
ret
section .data
message: db "hello world", 0
sudo apt install gcc-13-multilib g++-13-multilib
nasm -f elf32 hello-libc.asm
gcc -m32 hello-libc.o -o hello-libc
/usr/bin/ld: hello-libc.o: warning: relocation in read-only section `.text'
/usr/bin/ld: warning: creating DT_TEXTREL in a PIE
push message
引用了一个 绝对地址(symbol message),在 32 位汇编中生成的是R_386_32
重定位类型。这个重定位需要在.text
段中写入实际地址,PIE 模式禁止对.text
段的写操作,所以你会看到这两个警告
在 32 位 Linux 上真正的 PIE 安全代码只能用 C 或 gcc 生成汇编,nasm 写起来成本过高
gcc -m32 hello-libc.o -o hello-libc -no-pie
第一段代码(hello.asm):
- 更接近系统底层,直接调用 Linux 系统调用接口
- 没有依赖任何外部库,完全自己控制所有寄存器和调用
- 程序入口是
_start
,是 Linux 程序的最底层入口点 - 直接调用
sys_write
输出字符串,用sys_exit
退出 - 适用于编写极简裸机程序、操作系统内核、或者不想依赖 libc 的场合
第二段代码(hello-libc.asm):
- 运行在用户空间的普通 Linux 程序
- 需要链接器和 libc 支持
- 依赖
puts
函数实现打印,易用且跨平台(只要有 libc) - 程序入口是
main
,编译时要用gcc
链接
调用约定和参数传递
调用对象 | 参数传递方式 | 代码示例 |
---|---|---|
puts (C 函数) |
参数压栈(32 位时一般使用 cdecl) | push message; call puts |
Linux 系统调用 | 通过寄存器传递(eax=调用号,ebx, ecx, edx传参数) | mov eax,4; mov ebx,1; mov ecx,msg; mov edx,len; int 0x80 |
64 位 Linux:裸系统调用
;=============================================
; 数据段 - 存储常量和静态数据
;=============================================
section .data
msg db "hello, world", 10 ; 字符串 + 换行符(ASCII 10)
len equ $-msg ; 计算字符串长度
;=============================================
; 代码段 - 程序执行部分
;=============================================
section .text
global _start ; 声明程序入口点 _start
_start:
;-----------------------------------------
; 系统调用: sys_write(1)
; 参数:
; rdi = 文件描述符 (1 = stdout)
; rsi = 字符串地址
; rdx = 字符串长度
;-----------------------------------------
%define SYS_WRITE 1
%define SYS_EXIT 60
mov rax, SYS_WRITE ; 系统调用号:sys_write = 1
mov rdi, 1 ; 文件描述符:stdout
mov rsi, msg ; 指向字符串的地址
mov rdx, len ; 输出字符串的长度(字节数)
syscall ; 执行系统调用
;-----------------------------------------
; 系统调用: sys_exit(60)
; 参数:
; rdi = 退出状态码
;-----------------------------------------
mov rax, SYS_EXIT ; 系统调用号:sys_exit = 60
xor rdi, rdi ; 退出状态码 0
syscall ; 执行系统调用
nasm -f elf64 hello64.asm
ld hello64.o -o hello64
64 位 Linux:libc + RIP 相对寻址(兼容 PIE)
section .note.GNU-stack noalloc noexec nowrite
default rel
section .text
global main
extern puts
main:
lea rdi, [message] ; RIP 相对寻址
call [puts wrt ..got] ; 通过 GOT 间接调用 puts(兼容 PIE)
xor eax, eax
ret
section .rodata
message: db "hello, world", 0
nasm -f elf64 hello64-libc.asm -o hello64-libc.o
gcc hello64-libc.o -o hello64-libc
极简 ELF(Shellcode 风格)
BITS 32
org 0x08048000
ehdr: ; ELF header (52 bytes)
db 0x7F, "ELF", 1,1,1,0 ; e_ident
times 8 db 0
dw 2 ; e_type = ET_EXEC
dw 3 ; e_machine = EM_386
dd 1 ; e_version
dd _start ; e_entry
dd phdr - $$ ; e_phoff
dd 0 ; e_shoff
dd 0 ; e_flags
dw 52 ; e_ehsize
dw 32 ; e_phentsize
dw 1 ; e_phnum
dw 0 ; e_shentsize
dw 0 ; e_shnum
dw 0 ; e_shstrndx
phdr: ; Program header (32 bytes)
dd 1 ; p_type = PT_LOAD
dd 0 ; p_offset
dd $$ ; p_vaddr
dd $$ ; p_paddr
dd filesize ; p_filesz
dd filesize ; p_memsz
dd 5 ; p_flags = RX
dd 0x1000 ; p_align
_start:
xor eax, eax ; eax = 0
inc eax ; eax = 1 (sys_exit)
mov ebx, 42 ; ebx = 42
int 0x80
filesize equ $ - $$ ; total file size
nasm -f bin -o tiny_exit tiny_exit.asm
./tiny_exit
echo $?
🪟 Windows 编程
32 位 Windows Console 程序
global main
extern _GetStdHandle@4
extern _WriteConsoleA@20
extern _ExitProcess@4
section .data
msg db "Hello, World", 13, 10 ; 字符串 + \r\n
msglen equ $ - msg
section .text
main:
; 调用 GetStdHandle(-11) 获取标准输出句柄
push -11 ; STD_OUTPUT_HANDLE
call _GetStdHandle@4
; 保存返回的句柄
mov ebx, eax
; 调用 WriteConsoleA(handle, msg, len, &written, NULL)
push 0 ; lpReserved = NULL
push written ; lpNumberOfCharsWritten
push msglen ; nNumberOfCharsToWrite
push msg ; lpBuffer
push ebx ; hConsoleOutput
call _WriteConsoleA@20
; 退出程序 ExitProcess(0)
push 0
call _ExitProcess@4
section .bss
written resd 1 ; 存储写入字符数量
nasm -f win32 win32.asm -o win32.obj
rem vs 32位环境
\BuildTools\VC\Auxiliary\Build\vcvars32.bat
link win32.obj /entry:main /subsystem:console kernel32.lib /nologo /out:win32.exe
64 位 Windows Console 程序
default rel
extern GetStdHandle
extern WriteConsoleA
extern ExitProcess
section .data
msg db "Hello, World!", 13, 10
msglen equ $ - msg
section .bss
written resd 1
section .text
global main
main:
sub rsp, 40 ; 对齐栈 + 预留 shadow space
; 获取标准输出句柄
mov ecx, -11 ; STD_OUTPUT_HANDLE = -11
call GetStdHandle
; WriteConsoleA( hConsole, &msg, msglen, &written, 0 )
mov rcx, rax ; hConsole
lea rdx, [rel msg] ; 字符串地址
mov r8d, msglen ; 字符串长度
lea r9, [rel written] ; 返回写入的字节数
mov qword [rsp+32], 0 ; 第五个参数(必须为 0)
call WriteConsoleA
; ExitProcess(0)
xor ecx, ecx
call ExitProcess
nasm -f win64 win64.asm -o win64.obj
rem vs 64位环境
\BuildTools\VC\Auxiliary\Build\vcvars64.bat
link win64.obj /entry:main /subsystem:console kernel32.lib /nologo /out:win64.exe
win64 with crt
default rel
; 声明 CRT 和 Windows API 函数
extern GetStdHandle
extern WriteConsoleA
extern ExitProcess
extern __imp_GetStdHandle ; 可选:使用导入表方式调用
extern __imp_WriteConsoleA ; 可选
section .data
msg db "Hello, World!", 13, 10
msglen equ $ - msg
section .bss
written resd 1
section .text
; 定义 main 函数,由 CRT 调用
global main
main:
sub rsp, 40 ; 对齐栈 + shadow space (32字节对齐)
; 获取标准输出句柄 (STD_OUTPUT_HANDLE = -11)
mov ecx, -11
call GetStdHandle ; 或 call [__imp_GetStdHandle]
; WriteConsoleA(hConsole, &msg, msglen, &written, 0)
mov rcx, rax ; hConsole
lea rdx, [rel msg] ; 字符串地址
mov r8d, msglen ; 字符串长度
lea r9, [rel written] ; 返回写入的字节数
mov qword [rsp+32], 0 ; 第5个参数(保留)
call WriteConsoleA ; 或 call [__imp_WriteConsoleA]
; 返回 0(CRT 会调用 ExitProcess)
xor eax, eax ; return 0
add rsp, 40
ret
nasm -f win64 hello_crt.asm -o hello_crt.obj
link hello_crt.obj /subsystem:console /entry:main msvcrt.lib kernel32.lib /nologo /out:hello_crt.exe
Windows GUI 示例(MessageBoxA)
nasm可以通过info段,将更多的连接参数传递给link
;filename: sayhello.asm
;
;cmd> nasm -fwin32 -Xvc sayhello.asm
;cmd> link sayhello.obj
extern _MessageBoxA@16 ;in user32.dll
extern _ExitProcess@4 ;in kernel32.dll
global SayHello
global _WinMain
[SECTION .drectve info align=8]
db " /subsystem:windows"
db " /out:sayhello.exe"
db " /defaultlib:kernel32.lib"
db " /defaultlib:user32.lib"
db " /export:SayHello"
db " /entry:WinMain"
db " /merge:.rdata=.text",0
[SECTION .text USE32 align=16]
szTitle:
db "SayHello",0
szMsg:
db "Hello World!", 0
SayHello:
push 0 ;uType
push dword szTitle ;lpCaption
push dword szMsg ;lpText
push 0 ;hWnd
call _MessageBoxA@16
ret 16
_WinMain:
call SayHello
push 0
call _ExitProcess@4
nasm -fwin32 sayhello.asm
link sayhello.obj
win64 gui
extern MessageBoxA ; user32.dll
extern ExitProcess ; kernel32.dll
global SayHello
global WinMain
[SECTION .drectve info align=8]
db " /subsystem:windows"
db " /out:sayhello.exe"
db " /defaultlib:kernel32.lib"
db " /defaultlib:user32.lib"
db " /export:SayHello"
db " /entry:WinMain"
db " /merge:.rdata=.text",0
section .data
szTitle db "SayHello", 0
szMsg db "Hello World!", 0
section .text
align 16
SayHello:
; Win64 参数传递:
; RCX, RDX, R8, R9 -> hWnd, lpText, lpCaption, uType
; 栈对齐:call 会推入返回地址,栈必须在 call 前 16 字节对齐
; 保证对齐 + 没有可变参数函数 -> 子程序里预留 32 字节 shadow space
sub rsp, 40 ; shadow space + alignment
xor rcx, rcx ; hWnd = NULL
lea rdx, [rel szMsg] ; lpText
lea r8, [rel szTitle] ; lpCaption
xor r9d, r9d ; uType = 0
call MessageBoxA
add rsp, 40
ret
WinMain:
; 主程序入口
sub rsp, 40
call SayHello
xor ecx, ecx ; uExitCode = 0
call ExitProcess
nasm -fwin64 sayhello-64.asm
link sayhello-64.obj
或者
link sayhello-64.obj /subsystem:windows /entry:WinMain /defaultlib:kernel32.lib /defaultlib:user32.lib /out:sayhello.exe
伪指令
汇编语言中的 伪指令不会被翻译成机器码指令,它们是 汇编器使用的“命令”或“提示”,用于控制程序结构、内存布局、段定义、宏替换等
它们在汇编阶段起作用,而不会影响最终可执行程序的行为
◉ 伪指令的作用
功能类别 | 示例 | 作用说明 |
---|---|---|
段定义 | section .text , section .data |
定义程序的不同内存段(代码段、数据段、只读段、堆栈段等) |
起始地址 | org 0x7C00 |
设置程序的装载地址(常用于 bootloader) |
对齐 | align 16 |
指定地址对齐,提升性能或满足 ABI 要求 |
填充数据 | times 510-($-$$) db 0 |
重复填充数据(用于生成固定长度文件、比如 boot sector) |
当前地址标记 | $ , $$ |
$ 表示当前地址,$$ 表示当前段起始地址 |
声明全局符号 | global _start |
告诉链接器这是程序的入口或可被外部访问的符号 |
声明外部符号 | extern puts |
表示当前文件中用到了外部定义的符号(如 libc 函数) |
注释/调试信息 | section .note.GNU-stack |
提供安全提示,告诉系统该程序不需要可执行栈(用于栈保护) |
宏定义/替换 | %define SYS_EXIT 60 |
类似于 C 的 #define ,提高代码可读性 |
编译器交互 | [section .drectve] (NASM) |
Windows 下告诉 MS 链接器额外参数,如 subsystem、默认库等 |
◉ 为什么需要引入伪指令?
- 控制程序结构和布局
- 指令只能操作数据,无法描述程序结构
- 伪指令允许开发者告诉汇编器“这些代码属于哪一段”、“我希望内存从哪个地址开始布局”等
- 提升可读性与维护性
- 使用宏和命名常量(如
%define BUFFER_SIZE 1024
)代替硬编码魔法数字
- 兼容链接器与操作系统
- 如
global _start
、.note.GNU-stack
等伪指令,是为了满足 ELF 或 PE 等目标文件格式的要求
- 生成特定用途的输出文件
- 比如引导扇区必须精确为 512 字节,最后 2 字节为 0xAA55,伪指令可用于自动填充和控制布局:
times 510-($-$$) db 0
dw 0xaa55
- 跨平台支持
- NASM 提供的一些指令(如
.drectve
)可用于适配 Windows PE 链接器,适应不同平台规则
◉ 常见伪指令示例解释
section .text
/ .data
/ .bss
定义代码段、数据段、未初始化数据段。告诉汇编器和链接器,哪些是代码、哪些是数据
org 0x7C00
指定当前 section 的起始装载地址。常用于写 bootloader,因为 BIOS 默认将 MBR 加载到 0x7C00
$
, $$
, filesize equ $ - $$
-
$
:当前地址 -
$$
:当前段开始地址 -
$ - $$
:表示程序当前已经生成的大小- 用于控制长度,比如 bootloader 不得超过 512 字节
-
示例:
times 510 - ($ - $$) db 0 dw 0xAA55
.note.GNU-stack
不会生成任何代码。它告诉链接器:
“这个目标文件不需要执行权限的栈”
如果省略这个段,链接器可能发出警告,甚至给你的程序分配可执行栈,带来潜在安全隐患(如 ROP 攻击)
default rel
启用默认的 RIP-relative 地址模式,用于 64 位 PIE 程序中生成位置无关代码
◉ 📌 总结:伪指令 vs 指令
类型 | 示例 | 编译后存在于可执行文件中? | 功能说明 |
---|---|---|---|
指令 | mov eax, 1 |
✅ 是 | 真正执行的机器码 |
伪指令 | section .text , org |
❌ 否 | 提示汇编器如何组织/生成机器码文件 |
◉ 汇编器对伪指令的处理流程分析
NASM的核心流程:
- 预处理(Preprocessing)
- 处理宏、
%define
、%include
等 - 替换符号、展开宏
- 处理宏、
- 词法分析(Lexing)
- 将文本源代码分割为词元(token):如
mov
,eax
,1
- 将文本源代码分割为词元(token):如
- 语法分析(Parsing)
- 判断每一行是:
- 指令(生成机器码)
- 标签(记录地址)
- 伪指令(不生成机器码,而是对内部状态或输出布局的控制)
- 判断每一行是:
- 符号表构建(Symbol Table)
- 收集所有标签、全局符号(
global
)、外部引用(extern
)等
- 收集所有标签、全局符号(
- 布局决策(Layout Planning)
- 解析
section
、.org
、.align
、.bss
、.note.*
等伪指令 - 决定内存段的地址、对齐方式、起始偏移等
- 解析
- 汇编码生成(Encoding)
- 真正生成机器码的只有实际指令
- 遇到
times N db 0
类伪指令,则生成 N 字节的填充
- 重定位信息生成(若有)
- 如果是目标文件(如
.o
),则生成重定位条目 - 伪指令也可能引入此类符号引用(如使用
extern
)
- 如果是目标文件(如
- 输出(Write File)
- 按段写出
.o
、.bin
、.elf
等格式 .bin
类型为纯平面(flat binary),遵从.org
和times
的精确字节布局
- 按段写出
ABI 知识
Windows x64 ABI
Windows 下的 64 位调用约定规定:
参数位置 | 寄存器 |
---|---|
第 1 个 | RCX |
第 2 个 | RDX |
第 3 个 | R8 |
第 4 个 | R9 |
之后参数 | 栈上传(压栈);从 rsp+32 起 |
- 必须保证
rsp
在调用前 16 字节对齐 - 所有调用前必须对齐栈,并保留 32 字节 Shadow Space
- 被调用者不可用 Shadow Space,必须自行维护局部栈帧
为什么是 sub rsp, 40
?
- ✅ 16 字节对齐的栈(Stack must be 16-byte aligned)
- 调用
CALL
指令时,RSP+8
必须是 16 的倍数(因为CALL
会推一个 8 字节的返回地址到栈上) - 所以在
CALL
之前,RSP
必须是 16 的倍数减去 8(即 8 mod 16),这样CALL
一压栈就对齐
- ✅ 调用任何函数前,必须为它预留 32 字节 shadow space(影子空间)
- 这是 Windows x64 ABI 要求的:调用者必须为被调用函数准备 32 字节的栈空间(即
rsp
向下腾出 4 个 64 位寄存器的空间) - 这块区域是 callee(被调用者)不能压栈自己用,只用于 ABI 固定用途
- 就算函数不使用这些空间,也必须预留,以保证调用约定的一致性
🧮 计算:
我们要调用一个函数,例如 MessageBoxA
,我们必须:
目的 | 字节数 |
---|---|
Shadow Space | 32 |
Stack Alignment | 8 |
合计 | 40 |
📌 补充:什么时候不是 40?
如果再额外压了栈(例如 push
了其他值),就要调整这个数字来重新对齐,例如:
push rbx ; -8
sub rsp, 32 ; total -40
或者:
sub rsp, 48 ; 为了对齐 + shadow + 自己临时变量
只要能保证:
调用函数时,
RSP+8
是 16 的倍数,且至少预留 32 字节 shadow space
就可以灵活调整
RIP-relative 寻址
一、背景:为什么引入 RIP-relative?
64 位模式中,寻址空间高达 2^64 = 16EB(Exabyte),远远超过 32 位 CPU 的 4GB 范围
如果所有内存访问都使用完整的 64 位绝对地址,会有两个问题:
- 机器码变长:每次访问内存都要编码一个完整的 64 位地址,非常浪费空间
- 不易重定位:你写死一个绝对地址,程序搬到别的地方就失效了
相比使用绝对地址,这种方式:
- 更短(代码空间只需 32 位偏移量)
- 支持 ASLR / PIE,实现可重定位
- 推荐在 64 位程序中默认开启
二、什么是 RIP-relative addressing?
x86-64 中,大多数数据访问都是通过:
[rip + offset]
来表示的。也就是:
从当前指令地址(RIP)加上一个偏移量,计算出最终内存地址
它是 64 位模式下默认的寻址方式之一
三、使用场景
场景 | 是否推荐使用 RIP-relative |
---|---|
访问 .data , .rodata 数据段 |
✅ 推荐 |
定位静态变量 | ✅ 推荐 |
构建位置无关代码 PIE | ✅ 必须 |
手动拼接代码或写内核代码 | ⚠ 需要配合 org 或手动绝对地址 |
四、和 gcc -no-pic
, -no-pie
的关联
特性 | 是否依赖 RIP-relative addressing |
---|---|
-fPIC , -fPIE |
✅ 依赖。必须使用 RIP-relative 或间接跳转等技术 |
-fno-pic , -no-pie |
❌ 不依赖。编译器会生成绝对地址寻址,可能不兼容 ASLR |
-pie |
✅ 链接器要求目标使用 RIP-relative 的位置无关代码 |
特性 | Windows x86_64 | Linux x86_64 |
---|---|---|
默认 ABI | 强制使用 RIP-relative addressing | 支持 RIP-relative,但不强制 |
普通可执行文件(默认) | 使用 RIP-relative addressing | 可能使用绝对地址(非 PIE 模式) |
共享库 .dll / .so |
要求位置无关,使用 RIP-relative | 必须使用 -fPIC ,即使用 RIP-relative |
可执行文件 .exe / ELF |
默认启用 ASLR+PIE,强制位置无关 | 取决于 -pie / -no-pie |
default rel (汇编)是否必要? |
✅ 是必要的(必须用 RIP) | ⚠ 视是否启用 PIE/PIC,建议用 |
场景 | 推荐写法 | 编译/链接建议 |
---|---|---|
直接系统调用 | _start + int 0x80/syscall |
nasm + ld |
使用 libc | main + call puts |
nasm + gcc |
GUI(Windows) | MessageBoxA/ExitProcess |
nasm + link + .drectve |
高安全要求(PIE) | default rel + GOT |
gcc -fPIE / -no-pie 控制 |