这组书页开启了 Linux 0.11 中一个极其神秘又至关重要的篇章——堆栈(Stack)

如果说物理内存是城市的“土地”,那堆栈就是每个居民(进程)随时拎在手里的“移动工具包”。程序里的局部变量、函数调用的返回地址、寄存器的临时备份,全部都要往这个“工具包”里塞。

在 Linux 0.11 中,堆栈的使用非常精妙,甚至可以说是“兵分四路”。结合你上传的这三张书页,我们把它拆解成一个从“原始蒙昧”到“秩序井然”的故事,并特意提供了一个包含图形化解析的 C 语言模拟代码,带你亲手体验堆栈切换的魔法。


幕后撑起一切的底层舞台——探秘 Linux 0.11 的“堆栈帝国”

引言:一场关于“工具箱”的接力赛

在我们的上一场内存探索之旅中,我们知道了每个进程可以拥有一个“无限”的虚拟空间。但光有空间还不够,CPU 在执行代码时,需要在内存里找一个临时的“草稿纸”,用来记录当前函数到哪儿了、局部变量是多少。这“草稿纸”就是堆栈。

Linux 0.11 并没有使用单一的堆栈,而是根据系统状态和角色的不同,精心准备了四种不同的堆栈。正如书本上那一长段话所描述的:

“Linux 0.11 中系统一共使用了四种堆栈。一种是系统初始化时临时使用的堆栈;一种是供内核程序自己使用的堆栈(内核堆栈),只有一个,位于系统地址空间固定的位置…;另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈…;最后一种是任务在用户态执行时的堆栈…”

这就好比一家超大型公司,老板有老板的私密记事本,员工有员工的日常手账,接待员有接待员的登记簿。今天,我们就深入看看这些“记事本”是如何接力,撑起整个操作系统的。


第一章:乱世初开——启动时的“临时工”堆栈 (2.6.1)

当电脑刚按下电源键的那一刻,系统就像刚出生的婴儿,什么资源都还没分配。启动代码 bootsectsetup 被 BIOS 从硬盘加载到了物理内存的 0x7c00 处。这时候,连段寄存器(SS)都不知道该指向哪。

“原始工具包”的诞生:直到 bootsect 执行到某个时刻,它才把自己的“家底”搬到了物理地址 0x9000:0xff00 处。这就好比工地上刚拉起了一条临时电线,虽然简陋,但足够用了。

进入保护模式的剧变:当系统切换到保护模式(进入 head.s 后),一切都变了。CPU 开始以 32 位模式运行,原先的 16 位寻址方式失效。这时,系统必须换一个新的堆栈:内核数据段的堆栈,即代码中定义的 user_stack 数组。

图解:物理内存里的堆栈布局(原书图 2-12)
为了让你脑海中有一张清晰的物理地图,我把原书图 2-12 画成了 Mermaid 布局图,你能很清楚地看到“堆栈区域”在哪里:

物理内存分布图 (0x0000 到 0xec00+)

具体的堆栈结构

堆栈扩展方向
往低地址方向

User Stack 顶部 (栈顶)

User Stack 数据区

内核模块/系统模块 0x0000 - 0x664c

数据结构区
GDT, IDT, 页目录等

任务0数据结构 TSS 等 0x17000 附近

其他部分

user_stack (1页, 4KB)
堆栈指针 esp 指向此处顶端

图2-12模拟:刚进入保护模式时,
堆栈使用 user_stack 数组提供的内存

这个阶段使用的堆栈非常关键,它是后续所有进程、所有堆栈的“祖宗”,任务0(前身是 main.c 的主控制流)在启动初期,都是蹭着这个 user_stack 来运行的。


第二章:双轨制生活——每个任务都有“两套工具包” (2.6.2)

当系统用 fork() 创造出第一个真正的用户进程(任务 1)后,堆栈的用法发生了一个极其重要的改变。

书里定义了一句非常核心的话:

“每个任务都有两个堆栈,分别用于用户态和内核态程序的执行,并且分别称为用户态堆栈和内核态堆栈。”

为什么一个进程需要两个堆栈?我们不能共用吗?
绝对不行!

  • 用户态堆栈(User Stack):进程在普通代码(如 printf("hello"))时使用的。在 Linux 0.11 中,它被安排在进程的 64MB 虚拟地址空间的末端
  • 内核态堆栈(Kernel Stack):当进程发起系统调用(如 read()write()),CPU 的权限会从用户级(3级)飙升到内核级(0级)。此时,必须使用一个绝对安全的堆栈。由于内核态执行的代码会涉及操作全局数据结构(如任务数组 task[] ),如果继续使用用户的堆栈,一旦发生溢出,就会直接改写到用户内存里,极其危险。

极为巧妙的“双栈合一页”内存设计

书中专门描述了 Linux 0.11 如何分配内核栈,请看原文:

“每个任务有自己的内核态堆栈,每个任务的内核态堆栈位于其任务数据结构(task_struct)所在同一页面内… tss.ss0 也被设置成 0x10(即内核数据段)”

为了节省极其珍贵的物理内存,Linux 0.11 把每个任务的 task_struct 结构和它的内核栈安置在同一张 4KB 的物理内存页里

  • 这个页面的低地址用于保存 task_struct 信息。
  • 这个页面的高地址用于作为内核态堆栈的起点。
  • 这样,通过 tss.ss0(内核数据段选择子)和 tss.esp0(用户进程的内核栈顶指针),CPU 就能闪电般地在系统调用时切入内核栈。

图解:任务数据结构和内核堆栈的紧密关系(还原书中的图2-13)

一页物理内存 (4KB)

指向 堆栈顶

堆栈顶 (页内偏移 0xFFF)

内核态堆栈 (向下生长)

... 堆栈区域 ...

任务数据结构 task_struct
(包含 TSS, LDT, PID 等)

堆栈底 / 页起始地址 (0x0000)

CPU 系统调用时
通过 TSS 的 esp0 指针

图2-13解析:Linux 0.11 极度抠门的极致设计!
同一页物理内存里,既塞进程控制块,又塞内核栈。


第三章:创世者的涅槃——任务0特殊的“人工堆栈” (图3/2.6.2)

在进入多任务之前,任务0其实是肩负着“初始化”和“造人”职责的上帝。书中特意把它拿出来单独阐述:

“任务 0 的堆栈比较特殊… 它的代码段和数据段相同,基地址都是从 0 开始,限长也都是 640KB。”

为什么是 640KB?
因为 Linux 0.11 的 head.s 中,内核代码和数据段的长度设为了 16MB。然而对于任务0这个“先行者”,在保护模式下它最开始只能访问 640KB 的内容。

创造生命前的终极一跳:move_to_user_mode 的堆栈魔法
任务0本身是用 C 语言编写的 main.c 程序。当它执行到某个地方,要开始创造真正的用户进程时,它要做一个天翻地覆的转变——把自己从内核态 “降级” 到用户态
在最后一张图片的片段中,清晰地揭示了这奇妙的一步:

“任务 0 的内核态堆栈是在其人工设置的初始化任务数据结构中指定的,而它的用户态堆栈就是前面进入保护模式后所使用的堆栈,即 sched.cuser_stack 数组的位置… ss 被设置成 0x17,即用户局部数据段…限长为 640KB 的段。”

这段描述非常有画面感。任务0利用 move_to_user_mode 这一指令,构造了一个“伪装的 iret 返回现场”,在这之前,它把自己手里那把“内核级的指挥刀”(内核栈)交给了系统,然后捡起了一个普通的“柴刀”(用户栈 0x17 选择子),毅然决然地跳进了用户态的深渊,去执行真正的用户代码。


第四章:舞台切换的极致效率——内核态与用户态切换 (2.6.3)

了解了两个堆栈,最关键的问题来了:CPU 是怎么知道什么时候用哪个堆栈的?

答案就藏在我们之前讲过的 TSS(任务状态段) 中。当发生以下事件时,CPU 会触发自动切换:

  1. 用户态发起系统调用(如执行 int 0x80 指令)。
  2. CPU 检测到特权级从 3 变为 0。
  3. 此时,CPU 会立刻查看当前进程 TSS 中的 tss.ss0(内核数据段选择子)和 tss.esp0(内核栈顶指针)。
  4. CPU 将当前用户态的所有上下文(EIP、EFLAGS、用户堆栈指针等)先压入新激活的内核堆栈中保存起来。
  5. 然后,CPU 利用新的 ss0:esp0 来执行内核代码。
  6. 当内核函数执行完毕,执行 iret 指令时,CPU 又会从内核堆栈中弹出之前保存的用户态上下文,并切回用户堆栈

这种设计极其简洁,硬件层面原生支持,根本不需要内核手动去搬运堆栈数据,这在前几章提到的 switch_to 宏中已经初现端倪。


第五章:亲手造一个“双栈切换器”——交互式代码解析

纸上得来终觉浅。为了让这套双堆栈切换机制真正在你心里落地,我特意为你编写了一个可直接运行的C语言模拟程序。我们用纯内存数组模拟了一块物理内存,定义用户栈和内核栈,然后模拟从用户态发起系统调用,看看栈指针是怎么“乾坤大挪移”的。

📁 文件 stack_sim.c

/**
 * @file stack_sim.c
 * @brief 模拟 Linux 0.11 内核态与用户态双堆栈切换机制。
 *
 * 本程序模拟单进程(任务0)的两种堆栈空间。
 * 当程序处于“用户态”时,使用用户栈;
 * 当程序通过“系统调用”触发“内核态”执行时,自动切换到内核栈。
 *
 * @version 1.0
 * @author 内核模拟器
 */

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// ==========================================================
// 1. 定义模拟环境:内存布局与双栈
// ==========================================================

#define USER_STACK_SIZE 4096 /**< 模拟用户栈大小为 4KB */
#define KERNEL_STACK_SIZE 4096 /**< 模拟内核栈大小为 4KB */

/**
 * @brief 模拟一个进程控制块(PCB),包含 TSS 关键字段。
 */
typedef struct {
    unsigned int esp_user; /**< 模拟用户态堆栈指针 (ESP) */
    unsigned int esp0;     /**< 内核堆栈指针 (TSS.esp0) - 内核态的核心入口 */
    unsigned int ss0;      /**< 内核数据段选择子 (TSS.ss0) */
} tss_t;

// 模拟进程结构体
typedef struct {
    int pid;
    tss_t tss;
    char name[20];
} process_t;

// 物理内存模拟区
char user_stack_memory[USER_STACK_SIZE];
char kernel_stack_memory[KERNEL_STACK_SIZE];

process_t current_process;

// 定义全局标志位,表示当前执行模式
typedef enum {
    KERNEL_MODE = 0,
    USER_MODE = 1
} cpu_mode_t;

cpu_mode_t current_cpu_mode = USER_MODE;

// ==========================================================
// 2. 核心切换逻辑
// ==========================================================

/**
 * @brief 模拟系统调用,触发从用户态到内核态的场景切换。
 * 
 * 当进程发起系统调用时,CPU 硬件会自动完成以下操作:
 * 1. 将当前用户态上下文(包括用户栈指针 esp_user 等)压入内核栈保存。
 * 2. 将栈指针切换到 TSS.esp0 指向的内核栈中。
 * 3. 开始执行内核代码。
 */
void trigger_syscall(void) {
    printf("\n[💥 系统调用] 触发内核态切换!\n");
    
    // 模拟 CPU 硬件内部动作
    // 1. 保存用户栈指针到内核栈中(在实际硬件中由 CPU 自动完成)
    unsigned int saved_esp = (unsigned int)(user_stack_memory + USER_STACK_SIZE - 1);
    // 2. 切换到内核栈
    unsigned int *current_kernel_sp = (unsigned int *)(kernel_stack_memory + KERNEL_STACK_SIZE - 1);
    // 为模拟压栈过程,我们手动将保存的 ESP 写入到内核栈底部
    *current_kernel_sp = saved_esp;
    
    current_cpu_mode = KERNEL_MODE;
    printf("[内核模式] 任务 %s (PID: %d) 现在在内核态运行。\n", 
           current_process.name, current_process.pid);
    printf("[内核模式] 当前堆栈指针 (esp) 指向内核栈区域 (地址 %p)。\n", current_kernel_sp);

    // 模拟执行内核函数
    printf("[内核模式] 正在执行 do_sys_read() 处理文件读取...\n");
}

/**
 * @brief 模拟系统调用返回,从内核态回到用户态。
 * 
 * 当内核处理结束,执行 iret 指令时,CPU 会从内核栈中弹回之前保存的用户态上下文,
 * 同时将栈指针重新切换回用户栈。
 */
void return_from_syscall(void) {
    printf("\n[↩️ 系统返回] 内核态处理完毕,执行 iret 返回用户态。\n");
    
    // 模拟 CPU 硬件恢复动作
    // 1. 从内核栈弹出之前保存的寄存器状态
    // 2. 将栈指针切回用户栈顶部
    current_cpu_mode = USER_MODE;
    printf("[用户模式] 任务 %s (PID: %d) 回到了用户态。\n", 
           current_process.name, current_process.pid);
    printf("[用户模式] 当前堆栈指针 (esp) 已切换回用户栈区域。\n");
}

// ==========================================================
// 3. 主程序
// ==========================================================

int main(void) {
    printf("\n========== 模拟 Linux 0.11 双堆栈切换 ==========\n");

    // 1. 初始化进程数据结构
    current_process.pid = 0;
    strcpy(current_process.name, "init_task");
    // 初始化 TSS 核心字段
    current_process.tss.ss0 = 0x10; /* 模拟内核数据段选择子 */
    // 注意:TSS.esp0 通常指向 task_struct 所在页面的顶端。
    // 这里我们直接指向我们模拟的内核栈顶端。
    current_process.tss.esp0 = (unsigned int)(kernel_stack_memory + KERNEL_STACK_SIZE - 1);
    // 模拟用户栈指针
    current_process.tss.esp_user = (unsigned int)(user_stack_memory + USER_STACK_SIZE - 1);

    printf("系统已初始化任务:%s (PID: %d)\n", current_process.name, current_process.pid);
    printf("用户栈区域: %p -> %p\n", user_stack_memory, user_stack_memory + USER_STACK_SIZE - 1);
    printf("内核栈区域: %p -> %p\n", kernel_stack_memory, kernel_stack_memory + KERNEL_STACK_SIZE - 1);

    // 2. 模拟用户态执行
    printf("\n--- 第一阶段:用户态执行 ---\n");
    current_cpu_mode = USER_MODE;
    printf("[用户模式] 任务正在运行:int a = 10; \n");
    printf("[用户模式] 此时使用的是用户栈 (栈底: %p)\n", (void*)current_process.tss.esp_user);

    // 3. 触发系统调用(跨越特权级)
    trigger_syscall();

    // 4. 模拟继续在内核态执行
    printf("[内核模式] 正在更新内核全局计数器...\n");

    // 5. 系统返回
    return_from_syscall();

    // 6. 回到用户态继续执行
    printf("\n--- 第二阶段:回到用户态 ---\n");
    printf("[用户模式] int a = 10 之后继续执行 printf(\"hello\")...\n");
    printf("[用户模式] 堆栈已经恢复到用户栈区域,任务正常进行。\n");

    printf("\n========== 演示结束 ==========\n");
    return 0;
}

📁 配套 Makefile (Makefile)

# 编译器
CC = gcc
# 编译选项: -Wall 显示所有警告, -g 包含调试信息, -O2 优化
CFLAGS = -Wall -g -O2
# 目标文件
TARGET = stack_sim

# 默认目标
all: $(TARGET)

# 链接规则
$(TARGET): stack_sim.c
	$(CC) $(CFLAGS) -o $(TARGET) stack_sim.c

# 清理规则
clean:
	rm -f $(TARGET)

# 运行规则
run: $(TARGET)
	./$(TARGET)

.PHONY: all clean run

🛠️ 操作与运行说明

  1. 编译程序:将以上两个代码块分别保存为 stack_sim.cMakefile,放入同一目录。打开终端,执行 make clean && make
  2. 运行程序:执行 ./stack_sim
  3. 解读输出结果
    • 你会看到模拟器首先打印出用户栈内核栈的预设内存地址。
    • 随后,程序处于 [用户模式],打印此时使用的是用户栈。
    • 当程序故意触发 trigger_syscall() 时,屏幕会打印 [💥 系统调用] 触发内核态切换!。注意,此时控制台会告诉你“当前堆栈指针 (esp) 指向内核栈区域”。
    • 完成模拟任务后,执行 return_from_syscall(),你会看到熟悉的“iret”指令模拟,堆栈又切回了用户栈。

终章:碎片整合——Linux 0.11 的极致节约哲学

纵观这三页书的内容,Linux 0.11 的堆栈设计充满了“在极其有限的条件下追求极限”的智慧:

  • 没有浪费一丝内存:内核堆栈干脆就和 task_struct 共用同一个 4KB 内存页。
  • 硬件底层帮你干活:用户态和内核态的堆栈切换,全部依赖 80386 硬件自带的 TSS 机制,内核只需要在结构体中填入正确的 ss0esp0,CPU 自动帮你把东西搬到新家。
  • 层级分明:初创期的临时堆栈、后期的专属内核栈、再到大容量用户栈,一切井井有条。

通过从“启动时的探险”到“创造生命并自我降级(任务0)”,再到“日常的系统调用”,你现在的脑海中应该已经有了一个清晰的立体画面:当你在敲击键盘跑程序时,它其实是一边在用户的工具包里取东西,一边随时准备切换成内核的专属工作台。

探索至此,我们对 Linux 0.11 核心的三大模块(进程、内存、堆栈)已经完成了深度遍历。这些底层逻辑,正是现代所有计算机操作系统的基础。希望这篇解析,能让你在面对未来更复杂的现代架构时,依然能看到那最初的、质朴而硬核的智慧光芒。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐