一文打通 Linux 与 Windows 下的 NASM 汇编编程:从最底层系统调用、libc 接口,到 GUI 程序与调用约定详解,涵盖 32 位与 64 位写法,并剖析构建、链接、重定位背后的机制

🧠 前言

nasm 是为 模块化和可移植性 而设计的 80x86 汇编器。它语法简洁,类似 Intel 风格,并支持丰富的输出格式,如:

  • ELF(Linux 下常用)
  • Win32/Win64(适配 Windows PE 格式)
  • Binary(适合写 bootloader 或 shellcode)

🐧 Linux 编程

🚀 快速开始:最简 Exit 示例

exit.asm
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:裸系统调用

hello.asm
;=============================================
; 数据段 - 存储常量和静态数据
;=============================================
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)

hello-libc.asm
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:裸系统调用

hello64.asm
;=============================================
; 数据段 - 存储常量和静态数据
;=============================================
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)

hello64-libc.asm
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 风格)

tiny_exit.asm
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 程序

win32.asm
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 程序

win64.asm
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

hello_crt.asm
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

sayhello.asm
;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

sayhello-64.asm
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、默认库等

◉ 为什么需要引入伪指令?

  1. 控制程序结构和布局
  • 指令只能操作数据,无法描述程序结构
  • 伪指令允许开发者告诉汇编器“这些代码属于哪一段”、“我希望内存从哪个地址开始布局”等
  1. 提升可读性与维护性
  • 使用宏和命名常量(如 %define BUFFER_SIZE 1024)代替硬编码魔法数字
  1. 兼容链接器与操作系统
  • global _start.note.GNU-stack 等伪指令,是为了满足 ELF 或 PE 等目标文件格式的要求
  1. 生成特定用途的输出文件
  • 比如引导扇区必须精确为 512 字节,最后 2 字节为 0xAA55,伪指令可用于自动填充和控制布局:
times 510-($-$$) db 0
dw 0xaa55
  1. 跨平台支持
  • 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的核心流程:

  1. 预处理(Preprocessing)
    • 处理宏、%define%include
    • 替换符号、展开宏
  2. 词法分析(Lexing)
    • 将文本源代码分割为词元(token):如 mov, eax, 1
  3. 语法分析(Parsing)
    • 判断每一行是:
      • 指令(生成机器码)
      • 标签(记录地址)
      • 伪指令(不生成机器码,而是对内部状态或输出布局的控制)
  4. 符号表构建(Symbol Table)
    • 收集所有标签、全局符号(global)、外部引用(extern)等
  5. 布局决策(Layout Planning)
    • 解析 section.org.align.bss.note.* 等伪指令
    • 决定内存段的地址、对齐方式、起始偏移等
  6. 汇编码生成(Encoding)
    • 真正生成机器码的只有实际指令
    • 遇到 times N db 0 类伪指令,则生成 N 字节的填充
  7. 重定位信息生成(若有)
    • 如果是目标文件(如 .o),则生成重定位条目
    • 伪指令也可能引入此类符号引用(如使用 extern
  8. 输出(Write File)
    • 按段写出 .o.bin.elf 等格式
    • .bin 类型为纯平面(flat binary),遵从 .orgtimes 的精确字节布局

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


  1. 16 字节对齐的栈(Stack must be 16-byte aligned)
  • 调用 CALL 指令时,RSP+8 必须是 16 的倍数(因为 CALL 会推一个 8 字节的返回地址到栈上)
  • 所以在 CALL 之前RSP 必须是 16 的倍数减去 8(即 8 mod 16),这样 CALL 一压栈就对齐

  1. 调用任何函数前,必须为它预留 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 位绝对地址,会有两个问题:

  1. 机器码变长:每次访问内存都要编码一个完整的 64 位地址,非常浪费空间
  2. 不易重定位:你写死一个绝对地址,程序搬到别的地方就失效了

相比使用绝对地址,这种方式:

  • 更短(代码空间只需 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 控制

ref

NASM-Tutorial-CN