Pintos Loader.S 详解(七):puts 函数
Pintos 引导加载程序中 puts 函数的巧妙实现——字符串直接跟在 call 指令后面。
概述
puts 是一个非常巧妙的字符串打印函数。它的独特之处在于:字符串不是通过参数传递,而是直接跟在 call 指令后面。这种设计极大地节省了代码空间。
原始代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#### Print string subroutine. To save space in the loader, this
#### subroutine takes its null-terminated string argument from the
#### code stream just after the call, and then returns to the byte
#### just after the terminating null. This subroutine preserves all
#### general-purpose registers.
puts: xchg %si, %ss:(%esp)
push %ax
next_char:
mov %cs:(%si), %al
inc %si
test %al, %al
jz 1f
call putc
jmp next_char
1: pop %ax
xchg %si, %ss:(%esp)
ret
调用方式
普通的字符串打印函数通常这样调用:
1
2
3
4
5
# 传统方式(需要额外存储字符串地址)
mov $string_addr, %si
call print_string
...
string_addr: .string "Hello"
但 Pintos 的 puts 这样调用:
1
2
3
4
# Pintos 方式(字符串紧跟在 call 后面)
call puts
.string "Hello"
# 执行完后,直接从这里继续
优势:
- 不需要单独存储字符串地址
- 代码更紧凑
- 字符串和调用点在一起,更易读
前置知识
call 指令的工作原理
当执行 call puts 时:
- 压栈返回地址:将下一条指令的地址压入栈
- 跳转:跳转到
puts函数
1
2
3
4
5
6
7
8
9
10
执行前: 执行 call 后:
代码: 栈:
┌─────────────┐ ┌─────────────┐
│ call puts │ │ 返回地址 │ ← ESP
├─────────────┤ ← 返回地址 ├─────────────┤
│ "Hello" │ │ ... │
├─────────────┤ └─────────────┘
│ 下一指令 │
└─────────────┘
关键点:返回地址指向的是字符串的开始,不是下一条真正的指令!
栈帧结构
1
2
3
4
5
6
7
高地址
┌─────────────┐
│ ... │
├─────────────┤
│ 返回地址 │ ← ESP 指向这里(SS:ESP)
└─────────────┘
低地址
xchg 指令
xchg 指令交换两个操作数的值:
1
2
3
4
5
xchg %si, %ss:(%esp)
# 等价于:
temp = %si
%si = [SS:ESP]
[SS:ESP] = temp
逐行详解
第 1 行:获取字符串地址
1
puts: xchg %si, %ss:(%esp)
这做了什么?
交换 SI 寄存器和栈顶的值(返回地址)。
执行前:
- SI = 某个值(需要保存)
- [SS:ESP] = 返回地址(指向字符串)
执行后:
- SI = 返回地址(现在指向字符串)
- [SS:ESP] = 原来的 SI 值(已保存)
为什么用 xchg?
一石二鸟:
- 把返回地址(字符串地址)加载到 SI
- 同时保存原来的 SI 值(放到栈上)
为什么是 %ss:(%esp) 而不是 (%esp)?
在实模式下,默认栈操作使用 SS 段。为了明确和安全,显式写出 %ss:。
第 2 行:保存 AX
1
push %ax
保存 AX 寄存器,因为后面要用它来处理字符。函数承诺”保留所有通用寄存器”。
栈状态:
1
2
3
4
5
┌─────────────┐
│ 原来的 AX │ ← ESP
├─────────────┤
│ 原来的 SI │
└─────────────┘
第 3-4 行:读取字符
1
2
3
next_char:
mov %cs:(%si), %al
inc %si
mov %cs:(%si), %al:
- 从 CS:SI 地址读取一个字节
- 存入 AL 寄存器
为什么用 CS 段?
字符串在代码中(紧跟 call 指令后),所以在代码段(CS)内。
inc %si:
- SI 加 1,指向下一个字符
第 5-6 行:检查字符串结束
1
2
test %al, %al
jz 1f
test %al, %al:
- 执行 AL AND AL
- 只设置标志位,不保存结果
- 如果 AL = 0,零标志 ZF = 1
jz 1f:
- Jump if Zero:如果 ZF = 1,跳转到标签
1: 1f表示向前(forward)找标签1- 遇到 null 终止符时结束循环
为什么用 test 而不是 cmp $0, %al?
| 指令 | 机器码 | 字节数 |
|---|---|---|
test %al, %al | 84 C0 | 2 |
cmp $0, %al | 3C 00 | 2 |
字节数相同,但 test 是更常见的惯用法。
第 7-8 行:打印字符并循环
1
2
call putc
jmp next_char
call putc:调用字符打印函数(稍后详解)
jmp next_char:跳回循环开始,处理下一个字符
第 9-10 行:恢复寄存器
1
2
1: pop %ax
xchg %si, %ss:(%esp)
pop %ax:恢复原来的 AX 值
xchg %si, %ss:(%esp):
- 交换 SI 和栈顶的值
- SI 恢复为原来的值
- 栈顶变成新的返回地址(字符串结束后的位置)
这是关键! 现在栈顶的返回地址指向字符串的 null 终止符之后——也就是真正的下一条指令。
第 11 行:返回
1
ret
从栈中弹出返回地址并跳转。由于栈顶已经被更新为字符串之后的地址,所以会正确返回到调用者的下一条指令。
执行过程详解
假设有以下代码:
1
2
3
call puts
msg: .string "Hi"
next: mov $1, %ax
步骤 1:执行 call puts
1
2
3
4
5
6
7
8
9
代码布局:
地址 内容
0x100 call puts (E8 xx xx)
0x103 'H' (48)
0x104 'i' (69)
0x105 '\0' (00)
0x106 mov $1, %ax (下一指令)
栈: [0x103] ← 返回地址指向 'H'
步骤 2:xchg %si, %ss:(%esp)
1
2
SI = 0x103 (字符串地址)
栈: [原SI值]
步骤 3:push %ax
1
2
栈: [原AX值]
[原SI值]
步骤 4-8:打印循环
1
2
3
迭代 1: 读取 'H' (0x103), 打印, SI = 0x104
迭代 2: 读取 'i' (0x104), 打印, SI = 0x105
迭代 3: 读取 '\0' (0x105), 发现是 0, 跳出循环
步骤 9:pop %ax
1
2
AX = 原来的值
栈: [原SI值]
步骤 10:xchg %si, %ss:(%esp)
1
2
SI = 原来的值
栈: [0x106] ← 现在指向 'mov $1, %ax'
步骤 11:ret
1
跳转到 0x106, 执行 mov $1, %ax
图解执行流程
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
调用前 执行 puts 中 返回后
代码:
┌─────────────┐
│ call puts │──────────────────────────────────────────────────┐
├─────────────┤ │
│ 'H' │◄──── SI 从这里开始 ──────────────────────────────│
├─────────────┤ │ │
│ 'i' │ │ 逐字符读取并打印 │
├─────────────┤ │ │
│ '\0' │ ▼ │
├─────────────┤◄──── SI 结束在这里 ─────────────────────────────│
│ mov $1, %ax │──────────────────────────────────────────────────┘
└─────────────┘ ▲ 返回到这里
│
└── SI 最终指向这里
(null 之后)
为什么这样设计?
传统方法的问题
1
2
3
4
5
6
7
8
9
# 方法 1:用寄存器传递地址
mov $msg, %si
call print_string
...
msg: .string "Hello"
# 问题:
# - 需要额外的 mov 指令
# - 字符串远离调用点,不直观
1
2
3
4
5
6
7
8
9
10
# 方法 2:用栈传递地址
push $msg
call print_string
add $2, %sp # 清理栈
...
msg: .string "Hello"
# 问题:
# - 需要 push 和清理栈
# - 更多字节
Pintos 方法的优势
1
2
3
4
5
6
7
8
call puts
.string "Hello"
# 自动从这里继续
# 优势:
# - 最少的字节数
# - 字符串紧跟调用,直观
# - 不需要清理
节省的空间:
| 方法 | 字节数 |
|---|---|
| 传统方法 | 5-8 字节 |
| Pintos 方法 | 仅 call 的 3 字节 |
常见问题
Q1: 为什么要保存所有寄存器?
调用者不知道 puts 会修改哪些寄存器。为了安全,puts 保证不破坏任何通用寄存器的值。这样调用者可以放心使用。
Q2: 如果字符串中有 ‘\0’ 会怎样?
字符串会在第一个 ‘\0’ 处截断。这是 C 语言的标准行为(null 终止字符串)。
Q3: xchg 指令是原子的吗?
在单处理器系统上,xchg 指令是原子的。在多处理器系统上,xchg 访问内存时会自动加锁。但在引导阶段,只有一个处理器在运行,所以这不是问题。
Q4: 为什么用 %cs:(%si) 而不是 (%si)?
在实模式下,不同的段有不同的用途:
- CS:代码段
- DS:数据段
- SS:栈段
字符串在代码中,所以必须用 CS 段来访问。如果用 DS(默认),可能指向错误的位置。
Q5: 这种技术有名字吗?
这种技术有时被称为 “inline string” 或 “embedded string” 技术。它在早期的汇编程序和引导加载程序中很常见。
类似技术的现代应用
虽然这种技术现在不常见,但类似的思想在其他地方出现:
Position-Independent Code (PIC)
1
2
3
call get_ip
get_ip:
pop %ebx # EBX = 当前指令地址
这种技术用于获取当前代码的地址,用于位置无关代码。
ARM 的 PC-relative 寻址
ARM 处理器有专门的指令从相对 PC 的位置加载数据,类似的思想。
练习思考
如果不使用
xchg,需要多少条指令来实现相同的功能?为什么
push %ax在xchg之后而不是之前?如果在字符串中间有
\0,如.string "Hel\0lo",会打印什么?这种技术在 32 位或 64 位模式下是否还有效?需要什么修改?
如果
puts函数本身需要调用其他函数,栈的变化会如何?
练习答案
点击查看答案 1
不使用 xchg 实现相同功能需要更多指令:
puts:
# 不用 xchg 的版本
push %si # 保存原 SI
mov %ss:2(%esp), %si # 从栈中获取返回地址
push %ax
# ... 循环代码 ...
pop %ax
mov %si, %ss:4(%esp) # 更新返回地址
pop %si # 恢复原 SI
ret
对比:
xchg版本:1 条指令,2-3 字节- 非
xchg版本:至少 4-5 条指令,8+ 字节
xchg 的优势:
- 同时完成读取和保存
- 原子操作,更安全
- 更少的代码空间
点击查看答案 2
push %ax 在 xchg 之后的原因:
- 栈结构关系:
xchg %si, %ss:(%esp)操作的是栈顶(ESP 指向的位置)- 这个位置当前是返回地址
- 如果先
push %ax,返回地址就不在栈顶了
- 如果项序稍改:
# 错误版本 puts: push %ax # AX 在栈顶 xchg %si, %ss:(%esp) # 这会交换 SI 和 AX,不是返回地址! - 正确项序的栈状态:
1 2 3 4 5 6
进入时: xchg后: push ax后: ┌────────┐ ┌────────┐ ┌────────┐ │返回地址│ │原SI值 │ │ AX │ ← ESP └────────┘ └────────┘ ├────────┤ ↑ESP ↑ESP │原SI值 │ └────────┘
点击查看答案 3
如果字符串是 .string "Hel\0lo",只会打印 “Hel”。
原因:
puts使用test %al, %al检查 null 终止符- 遇到第一个
\0时,循环结束 - 后面的 “lo” 永远不会被读取
更重要的问题:
- 返回地址会指向第一个
\0之后 - 也就是 “lo” 的开始位置
- CPU 会尝试执行 “lo\0” 作为机器码!
机器码解释:
1
2
3
4
5
6
7
8
'l' = 0x6C
'o' = 0x6F
'\0' = 0x00
字节序列:6C 6F 00
可能被解释为:
insb (%dx), %es:(%edi) # 6C
outsb # 6F...
这会导致未定义行为或崩溃。
点击查看答案 4
这种技术在 32 位或 64 位模式下仍然有效,但需要修改:
32 位模式:
puts32:
xchg %esi, (%esp) # 32 位寄存器和 32 位返回地址
push %eax
next_char:
mov (%esi), %al # 从平坦地址空间读取
inc %esi
test %al, %al
jz done
# ... 输出字符 ...
jmp next_char
done:
pop %eax
xchg %esi, (%esp)
ret
64 位模式:
puts64:
xchg %rsi, (%rsp) # 64 位
push %rax
# ... 类似逻辑 ...
主要变化:
- 寄存器名称:SI → ESI → RSI
- 地址宽度:16 位 → 32 位 → 64 位
- 不需要段前缀:保护模式使用平坦地址空间
点击查看答案 5
puts 调用 putc 时的栈变化:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
调用 puts 前:
┌────────────┐
│ 返回地址 │ ← ESP
│ (指向字符串) │
└────────────┘
xchg 后:
┌────────────┐
│ 原 SI 值 │ ← ESP
└────────────┘
SI = 字符串地址
push %ax 后:
┌────────────┐
│ AX │ ← ESP
├────────────┤
│ 原 SI 值 │
└────────────┘
call putc 时:
┌────────────┐
│ putc返回地址 │ ← ESP
├────────────┤
│ AX │
├────────────┤
│ 原 SI 值 │
└────────────┘
putc 内部 pusha 后:
┌────────────┐
│ 所有寄存器 │ ← ESP (16 字节)
├────────────┤
│ putc返回地址 │
├────────────┤
│ AX │
├────────────┤
│ 原 SI 值 │
└────────────┘
关键点:
- 每层调用都会添加自己的返回地址和保存的寄存器
- 原始的 “SI 值” 始终在栈的固定偏移位置
- 返回时按相反顺序弹出,恢复栈状态
代码复习
完整的 puts 函数,带详细注释:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 打印 null 终止的字符串
# 字符串紧跟在 call 指令后面
# 保留所有通用寄存器
puts:
xchg %si, %ss:(%esp) # SI ← 返回地址(字符串)
# 同时保存原 SI
push %ax # 保存 AX
next_char:
mov %cs:(%si), %al # AL ← 下一个字符
inc %si # SI 前进
test %al, %al # 是否为 null?
jz 1f # 是,退出循环
call putc # 打印字符
jmp next_char # 继续
1: pop %ax # 恢复 AX
xchg %si, %ss:(%esp) # 恢复 SI
# 返回地址 ← SI(字符串之后)
ret # 返回到字符串之后
下一部分
puts 函数调用 putc 来打印单个字符。接下来我们分析 putc 函数。请参阅下一篇文章。