Skip to content
Published at:

10. 栈与内存布局

前 9 章我们一直在操作寄存器和立即数——数据从哪里来、算完往哪里去,都在 32 个通用寄存器的封闭世界里打转。但寄存器只有 32 个,真正的程序需要处理远超寄存器数量的数据:字符串、数组、结构体、以及函数嵌套调用时的上下文保存。本章引入内存布局的概念,并聚焦于汇编编程中最核心的内存区域——栈。

进程虚拟地址空间

操作系统为每个进程提供一个独立的虚拟地址空间。在 64 位 Linux 下,用户空间典型布局从高地址到低地址如下:

高地址  ┌─────────────────────┐ 0x7FFF_FFFF_FFFF
        │     OS 内核空间      │ (用户代码不可访问)
        ├─────────────────────┤ 0x0000_7FFF_FFFF_FFFF
        │      栈 (Stack)      │ ← sp,向低地址增长
        │        ↓            │
        │    ··· 空闲 ···     │
        │        ↑            │
        │    堆 (Heap)         │ 向高地址增长(malloc / sbrk)
        ├─────────────────────┤
        │      BSS 段          │ 未初始化全局变量(运行时清零)
        ├─────────────────────┤
        │    数据段 (.data)    │ 已初始化全局/静态变量
        ├─────────────────────┤
        │  只读数据段 (.rodata) │ 字符串常量、跳转表
        ├─────────────────────┤
        │    文本段 (.text)    │ CPU 执行的机器指令
低地址  └─────────────────────┘ 0x0000_0040_0000(典型)

文本段 (.text):存放编译后的机器指令。通常只读且可共享——同一程序多个实例共享同一份物理内存中的 .text 页。长度在编译时确定,运行时不变化。

只读数据段 (.rodata):存放字符串字面量、const 限定的全局数据、跳转表等只读内容。向该段写入会触发段错误(Segmentation Fault)。

数据段 (.data):存放已显式初始化的全局变量和静态局部变量。初始值作为二进制镜像的一部分存储在可执行文件中,加载时映射到内存。

BSS 段 (Block Started by Symbol):存放未初始化(或初始化为零)的全局/静态变量。BSS 不在可执行文件中占用空间——ELF 仅记录其大小,OS 加载时映射清零页。这个看似微小的优化在大型程序中节省可观的磁盘空间。

堆 (Heap):运行时动态分配的内存区域。C 中 malloc/free 和 C++ 中 new/delete 操作堆。堆向高地址增长(在栈和 BSS 之间),由 OS 的 brk/sbrk 系统调用(底层)和 malloc 实现(上层)共同管理。堆的管理是碎片化内存的经典问题——本章不展开。

栈 (Stack):函数调用栈帧所在。向低地址增长——每次压栈,sp 减小。这是与堆反向增长的设计:两者相向扩张以最大程度利用中间的空闲区域。栈的大小有 OS 限制(ulimit -s,通常 8MB),超出导致栈溢出(Stack Overflow)。

内核空间:用户态代码不可直接访问。系统调用(第 12 章)是用户态进入内核的唯一合法入口。

栈的基本操作

RISC-V 中,栈由寄存器 sp (x2) 管理。sp 始终指向栈顶——即当前已使用栈空间的最低有效地址。

压栈 (Push)

压栈 = sp 向低地址移动 8 字节,然后在新的 sp 位置写入数据:

asm
# 将 t0 的值压入栈
addi sp, sp, -8       # sp 减 8(栈向低地址生长)
sd   t0, 0(sp)        # 将 t0 存入 sp 指向的位置

为什么先减 sp 再存?因为 sp 指向已分配但可能无数据的栈顶。如果你先存再减,则多线程或信号处理可能覆盖你刚写的数据——这是 ABI 的约定。

出栈 (Pop)

出栈 = 从 sp 位置读取数据,然后 sp 向高地址移动 8 字节:

asm
# 将栈顶数据弹出到 t0
ld   t0, 0(sp)        # 从 sp 指向的位置加载到 t0
addi sp, sp, 8        # sp 加 8,释放栈空间

先读后加的顺序同样重要——sp 指向有效数据的起始位置。

批量压栈/出栈

实际函数中通常一次保存/恢复多个寄存器:

asm
# 保存 ra, s0, s1 到栈(典型的函数 prologue)
addi sp, sp, -24      # 分配 24 字节(3 个 64 位寄存器)
sd   ra, 16(sp)       # ra  保存在 sp+16
sd   s0, 8(sp)        # s0  保存在 sp+8
sd   s1, 0(sp)        # s1  保存在 sp+0

# ... 函数体 ...

# 恢复 ra, s0, s1 并返回(典型的函数 epilogue)
ld   ra, 16(sp)
ld   s0, 8(sp)
ld   s1, 0(sp)
addi sp, sp, 24       # 释放栈帧
ret

注意存储顺序:高偏移存 ra,低偏移存 s1。这是约定俗成——从高地址到低地址依次排列,使读取顺序与存储顺序一致时心智负担最小。你可以选择任何偏移,但一致性使调试更容易。

栈的对齐要求

RISC-V ABI 要求 sp 在函数入口处保持 16 字节对齐。这意味着每次分配栈空间时,总分配量必须是 16 的倍数。保存 3 个寄存器需要 24 字节(不是 16 的倍数),实际中通常通过额外 addi sp, sp, -32 来满足(多分配 8 字节未使用空间)。

asm
# 正确的 16 字节对齐写法
addi sp, sp, -32      # 32 = 16 的倍数,满足对齐
sd   ra, 24(sp)       # 实际只用了 24 字节
sd   s0, 16(sp)
sd   s1, 8(sp)
# sp+0 处留空,仅用于对齐填充

汇编器段指令

汇编器通过段指令(section directive)将后续代码/数据放入指定段。段的切换影响生成的机器码最终在可执行文件中的位置。

.section 指令

通用段切换指令:

asm
.section .text          # 后续内容进入文本段(代码)
.section .data          # 后续内容进入数据段(已初始化可写数据)
.section .bss           # 后续内容进入 BSS 段(零初始化数据)
.section .rodata        # 后续内容进入只读数据段

多数汇编器为常见段提供简写形式:

指令等价于段属性
.text.section .text可读可执行
.data.section .data可读可写
.bss.section .bss可读可写,不占文件空间
.rodata.section .rodata只读

各段的使用场景

asm
# === .text 段:代码 ===
.text
.globl _start
_start:
    la   a0, msg         # 引用 .rodata 中的标签
    la   a1, counter     # 引用 .data 中的标签
    la   a2, buffer      # 引用 .bss 中的标签
    # ...

# === .rodata 段:只读数据 ===
.section .rodata
msg:
    .asciz "Hello, RISC-V!\n"
errmsg:
    .asciz "An error occurred.\n"

# === .data 段:已初始化可写数据 ===
.data
counter:
    .dword 0             # 64 位整数,初值为 0
pi:
    .double 3.141592653589793
greeting:
    .asciz "System initialized."

# === .bss 段:未初始化数据 ===
.bss
buffer:
    .skip 1024           # 预留 1024 字节,运行时清零
result:
    .dword 0             # .bss 中的 .dword 不存初值,只占位

数据定义伪指令

汇编器提供一套伪指令用于在段中定义数据。这些伪指令在汇编时转换为二进制表示,嵌入可执行文件。

定长数据定义

伪指令大小说明
.byte v1, v2, ...1 字节定义 8 位值序列
.half v1, v2, ...2 字节定义 16 位值序列
.word v1, v2, ...4 字节定义 32 位值序列
.dword v1, v2, ...8 字节定义 64 位值序列
.quad v1, v2, ...8 字节.dword 的同义词
asm
.data
# 单值定义
my_byte:    .byte  0x42
my_half:    .half  0x1234
my_word:    .word  0xDEADBEEF
my_dword:   .dword 0x12345678_9ABCDEF0

# 序列定义——字节数组
byte_array: .byte  0, 1, 2, 3, 4, 5, 6, 7

# 混合定义——结构体模拟
person:     .dword 1001          # id
            .asciz "Alice"       # name (占 6 字节,含 '\0')
            .byte  25            # age
            .align 8             # 对齐到 8 字节边界

字符串定义

伪指令结尾处理说明
.ascii "str"无结尾符仅定义字符序列,不含 '\0'
.asciz "str"自动加 '\0'C 兼容的字符串
.string "str"自动加 '\0'.asciz 的同义词(GNU 汇编器)
asm
.section .rodata
# 三条字符串的对比
s1: .ascii "ABCD"       # 内存:0x41 0x42 0x43 0x44(无 '\0')
s2: .asciz "ABCD"       # 内存:0x41 0x42 0x43 0x44 0x00(有 '\0')
s3: .string "ABCD"      # 同 .asciz

# 转义字符支持
msg: .asciz "Line 1\nLine 2\tTabbed\n"
# C 风格转义:\n \t \r \0 \\ \" 等均支持

空间预留

伪指令语义
.skip n预留 n 字节,填充零
.skip n, fill预留 n 字节,填充 fill 值(GNU 扩展)
.space n.skip 的同义词
.zero n预留 n 字节,填充零
asm
.bss
buf_small:  .skip  256         # 预留 256 字节缓冲区
buf_large:  .space 4096        # 预留 4KB 缓冲区

.data
# .data 中的 .skip 会在文件中占用空间(填充值被写入 ELF)
padding:    .skip  16, 0xFF    # 16 字节全部填 0xFF
trap_table: .skip  512         # 512 字节异常向量表,填充零

对齐指令

.align n:将当前位置对齐到 2^n 字节边界。n 为指数——.align 3 = 2^3 = 8 字节对齐。

asm
.data
    .byte  0x01
    .align 3               # 对齐到 8 字节边界(跳过 7 字节填充)
aligned_dword:
    .dword 0x1234567890ABCDEF  # 该值一定从 8 字节边界开始

    .byte  0x02
    .align 2               # 对齐到 4 字节边界
aligned_word:
    .word  0xDEADBEEF      # 该值一定从 4 字节边界开始

未对齐的内存访问在 RISC-V 中有性能代价:某些实现不允许未对齐访问(触发异常),某些允许但有额外延迟。.align 保证数据结构按期望的边界对齐——这是性能关键代码(热循环中的数组)和硬件要求(原子指令必须对齐)的基本要求。

注意:GNU 汇编器的 .align 语法在不同目标平台上含义不同。在 RISC-V 平台上,.align n 的 n 确实是幂指数(2^n)。在其他平台(如 x86),.align n 中的 n 可能直接表示字节数——这是移植汇编代码时的常见陷阱。

完整示例:数据段布局

asm
# 一个程序的数据定义全貌
.section .rodata
prompt:
    .asciz "Enter a number: "
result_msg:
    .asciz "Result = %d\n"

.data
.align 3                    # 8 字节对齐
fib_table:
    .dword 0, 1, 1, 2, 3, 5, 8, 13, 21, 34   # 前 10 个斐波那契数

.align 3
config:
    .word  0x01              # mode: 4 字节
    .half  8080              # port: 2 字节
    .byte  1                 # debug: 1 字节
    .byte  0                 # padding(手动对齐)
    .dword 0x1000            # max_size: 8 字节(从 8 字节边界开始)

.bss
.align 3
io_buffer:
    .skip 4096               # 4KB I/O 缓冲区
user_data:
    .skip 256                # 256 字节用户数据区

.text
.globl main
main:
    # ... 使用上述数据 ...
    ret

这个例子展示了真实汇编程序中各段的典型组织方式:.rodata 存放格式字符串,.data 存放初始化数据表,.bss 存放运行缓冲区,.text 存放代码逻辑。

栈帧的完整视图

结合本章所有概念,这里给出一个函数调用时的完整栈帧结构:

高地址  ┌──────────────────────────┐
        │  调用者的栈帧             │ ← 调用者的 sp(fp 可能指向这里)
        ├──────────────────────────┤
        │  第 9+ 个传入参数         │ (由调用者压入,被调用者通过正偏移读取)
        │  返回地址 (ra)            │ (由调用者的 call 指令自动压入)
        │  被保存的寄存器 (s0-s11)  │ (被调用者若使用则压入)
        │  局部数组/变量           │ (被调用者分配的空间)
        │  传出参数区              │ (被调用者调用其他函数时使用的参数区)
低地址  ├──────────────────────────┤ ← 当前 sp
        │  未使用(栈生长方向↓)    │

这个结构将在下一章(调用约定)中详细展开——包括帧指针 fp 的作用、leaf 函数优化、以及 prologue/epilogue 的标准写法。

本章要点

  • 进程虚拟地址空间六段布局(text/rodata/data/bss/heap/stack),栈向低地址与堆向高地址相向增长
  • sp(x2) 始终指向栈顶:push = sp-8 再 sd,pop = ld 再 sp+8;多寄存器保存按高→低偏移排列
  • RISC-V ABI 要求 sp 16 字节对齐,栈帧分配量取 16 的倍数
  • .text/.data/.bss/.rodata 指令切换段;.byte/.half/.word/.dword/.asciz/.skip/.align 定义段内容
  • 数据定义伪指令在汇编时转换为二进制——.asciz 自动加 '\0',.align n 对齐到 2^n 边界,.bss 中的数据不占 ELF 文件空间