文章

Pintos Loader.S 详解(七):puts 函数

Pintos 引导加载程序中 puts 函数的巧妙实现——字符串直接跟在 call 指令后面。

Pintos Loader.S 详解(七):puts 函数

概述

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 时:

  1. 压栈返回地址:将下一条指令的地址压入栈
  2. 跳转:跳转到 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?

一石二鸟:

  1. 把返回地址(字符串地址)加载到 SI
  2. 同时保存原来的 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, %al84 C02
cmp $0, %al3C 002

字节数相同,但 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 的位置加载数据,类似的思想。


练习思考

  1. 如果不使用 xchg,需要多少条指令来实现相同的功能?

  2. 为什么 push %axxchg 之后而不是之前?

  3. 如果在字符串中间有 \0,如 .string "Hel\0lo",会打印什么?

  4. 这种技术在 32 位或 64 位模式下是否还有效?需要什么修改?

  5. 如果 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 的优势:

  1. 同时完成读取和保存
  2. 原子操作,更安全
  3. 更少的代码空间
点击查看答案 2

push %axxchg 之后的原因:

  1. 栈结构关系
    • xchg %si, %ss:(%esp) 操作的是栈顶(ESP 指向的位置)
    • 这个位置当前是返回地址
    • 如果先 push %ax,返回地址就不在栈顶了
  2. 如果项序稍改
    # 错误版本
    puts:
        push %ax              # AX 在栈顶
        xchg %si, %ss:(%esp)  # 这会交换 SI 和 AX,不是返回地址!
    
  3. 正确项序的栈状态
    1
    2
    3
    4
    5
    6
    
    进入时:     xchg后:       push ax后:
    ┌────────┐   ┌────────┐   ┌────────┐
    │返回地址│   │原SI值  │   │  AX    │ ← ESP
    └────────┘   └────────┘   ├────────┤
        ↑ESP        ↑ESP     │原SI值  │
                            └────────┘
    
点击查看答案 3

如果字符串是 .string "Hel\0lo",只会打印 “Hel”。

原因

  1. puts 使用 test %al, %al 检查 null 终止符
  2. 遇到第一个 \0 时,循环结束
  3. 后面的 “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
    
    # ... 类似逻辑 ...

主要变化

  1. 寄存器名称:SI → ESI → RSI
  2. 地址宽度:16 位 → 32 位 → 64 位
  3. 不需要段前缀:保护模式使用平坦地址空间
点击查看答案 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 函数。请参阅下一篇文章。

本文由作者按照 CC BY 4.0 进行授权