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 位置写入数据:
# 将 t0 的值压入栈
addi sp, sp, -8 # sp 减 8(栈向低地址生长)
sd t0, 0(sp) # 将 t0 存入 sp 指向的位置为什么先减 sp 再存?因为 sp 指向已分配但可能无数据的栈顶。如果你先存再减,则多线程或信号处理可能覆盖你刚写的数据——这是 ABI 的约定。
出栈 (Pop)
出栈 = 从 sp 位置读取数据,然后 sp 向高地址移动 8 字节:
# 将栈顶数据弹出到 t0
ld t0, 0(sp) # 从 sp 指向的位置加载到 t0
addi sp, sp, 8 # sp 加 8,释放栈空间先读后加的顺序同样重要——sp 指向有效数据的起始位置。
批量压栈/出栈
实际函数中通常一次保存/恢复多个寄存器:
# 保存 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 字节未使用空间)。
# 正确的 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 指令
通用段切换指令:
.section .text # 后续内容进入文本段(代码)
.section .data # 后续内容进入数据段(已初始化可写数据)
.section .bss # 后续内容进入 BSS 段(零初始化数据)
.section .rodata # 后续内容进入只读数据段多数汇编器为常见段提供简写形式:
| 指令 | 等价于 | 段属性 |
|---|---|---|
.text | .section .text | 可读可执行 |
.data | .section .data | 可读可写 |
.bss | .section .bss | 可读可写,不占文件空间 |
.rodata | .section .rodata | 只读 |
各段的使用场景
# === .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 的同义词 |
.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 汇编器) |
.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 字节,填充零 |
.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 字节对齐。
.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 可能直接表示字节数——这是移植汇编代码时的常见陷阱。
完整示例:数据段布局
# 一个程序的数据定义全貌
.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 文件空间