linux内核体系结构008:幕后撑起一切的底层舞台——探秘 Linux 0.11 的“堆栈帝国”
这组书页开启了 Linux 0.11 中一个极其神秘又至关重要的篇章——堆栈(Stack)。
如果说物理内存是城市的“土地”,那堆栈就是每个居民(进程)随时拎在手里的“移动工具包”。程序里的局部变量、函数调用的返回地址、寄存器的临时备份,全部都要往这个“工具包”里塞。
在 Linux 0.11 中,堆栈的使用非常精妙,甚至可以说是“兵分四路”。结合你上传的这三张书页,我们把它拆解成一个从“原始蒙昧”到“秩序井然”的故事,并特意提供了一个包含图形化解析的 C 语言模拟代码,带你亲手体验堆栈切换的魔法。
幕后撑起一切的底层舞台——探秘 Linux 0.11 的“堆栈帝国”
引言:一场关于“工具箱”的接力赛
在我们的上一场内存探索之旅中,我们知道了每个进程可以拥有一个“无限”的虚拟空间。但光有空间还不够,CPU 在执行代码时,需要在内存里找一个临时的“草稿纸”,用来记录当前函数到哪儿了、局部变量是多少。这“草稿纸”就是堆栈。
Linux 0.11 并没有使用单一的堆栈,而是根据系统状态和角色的不同,精心准备了四种不同的堆栈。正如书本上那一长段话所描述的:
“Linux 0.11 中系统一共使用了四种堆栈。一种是系统初始化时临时使用的堆栈;一种是供内核程序自己使用的堆栈(内核堆栈),只有一个,位于系统地址空间固定的位置…;另一种是每个任务通过系统调用,执行内核程序时使用的堆栈,我们称之为任务的内核态堆栈…;最后一种是任务在用户态执行时的堆栈…”
这就好比一家超大型公司,老板有老板的私密记事本,员工有员工的日常手账,接待员有接待员的登记簿。今天,我们就深入看看这些“记事本”是如何接力,撑起整个操作系统的。
第一章:乱世初开——启动时的“临时工”堆栈 (2.6.1)
当电脑刚按下电源键的那一刻,系统就像刚出生的婴儿,什么资源都还没分配。启动代码 bootsect 和 setup 被 BIOS 从硬盘加载到了物理内存的 0x7c00 处。这时候,连段寄存器(SS)都不知道该指向哪。
“原始工具包”的诞生:直到 bootsect 执行到某个时刻,它才把自己的“家底”搬到了物理地址 0x9000:0xff00 处。这就好比工地上刚拉起了一条临时电线,虽然简陋,但足够用了。
进入保护模式的剧变:当系统切换到保护模式(进入 head.s 后),一切都变了。CPU 开始以 32 位模式运行,原先的 16 位寻址方式失效。这时,系统必须换一个新的堆栈:内核数据段的堆栈,即代码中定义的 user_stack 数组。
图解:物理内存里的堆栈布局(原书图 2-12)
为了让你脑海中有一张清晰的物理地图,我把原书图 2-12 画成了 Mermaid 布局图,你能很清楚地看到“堆栈区域”在哪里:
这个阶段使用的堆栈非常关键,它是后续所有进程、所有堆栈的“祖宗”,任务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)
第三章:创世者的涅槃——任务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.c的user_stack数组的位置…ss被设置成0x17,即用户局部数据段…限长为 640KB 的段。”
这段描述非常有画面感。任务0利用 move_to_user_mode 这一指令,构造了一个“伪装的 iret 返回现场”,在这之前,它把自己手里那把“内核级的指挥刀”(内核栈)交给了系统,然后捡起了一个普通的“柴刀”(用户栈 0x17 选择子),毅然决然地跳进了用户态的深渊,去执行真正的用户代码。
第四章:舞台切换的极致效率——内核态与用户态切换 (2.6.3)
了解了两个堆栈,最关键的问题来了:CPU 是怎么知道什么时候用哪个堆栈的?
答案就藏在我们之前讲过的 TSS(任务状态段) 中。当发生以下事件时,CPU 会触发自动切换:
- 用户态发起系统调用(如执行
int 0x80指令)。 - CPU 检测到特权级从 3 变为 0。
- 此时,CPU 会立刻查看当前进程 TSS 中的
tss.ss0(内核数据段选择子)和tss.esp0(内核栈顶指针)。 - CPU 将当前用户态的所有上下文(EIP、EFLAGS、用户堆栈指针等)先压入新激活的内核堆栈中保存起来。
- 然后,CPU 利用新的
ss0:esp0来执行内核代码。 - 当内核函数执行完毕,执行
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
🛠️ 操作与运行说明
- 编译程序:将以上两个代码块分别保存为
stack_sim.c和Makefile,放入同一目录。打开终端,执行make clean && make。 - 运行程序:执行
./stack_sim。 - 解读输出结果:
- 你会看到模拟器首先打印出用户栈和内核栈的预设内存地址。
- 随后,程序处于
[用户模式],打印此时使用的是用户栈。 - 当程序故意触发
trigger_syscall()时,屏幕会打印[💥 系统调用] 触发内核态切换!。注意,此时控制台会告诉你“当前堆栈指针 (esp) 指向内核栈区域”。 - 完成模拟任务后,执行
return_from_syscall(),你会看到熟悉的“iret”指令模拟,堆栈又切回了用户栈。
终章:碎片整合——Linux 0.11 的极致节约哲学
纵观这三页书的内容,Linux 0.11 的堆栈设计充满了“在极其有限的条件下追求极限”的智慧:
- 没有浪费一丝内存:内核堆栈干脆就和
task_struct共用同一个 4KB 内存页。 - 硬件底层帮你干活:用户态和内核态的堆栈切换,全部依赖 80386 硬件自带的
TSS机制,内核只需要在结构体中填入正确的ss0和esp0,CPU 自动帮你把东西搬到新家。 - 层级分明:初创期的临时堆栈、后期的专属内核栈、再到大容量用户栈,一切井井有条。
通过从“启动时的探险”到“创造生命并自我降级(任务0)”,再到“日常的系统调用”,你现在的脑海中应该已经有了一个清晰的立体画面:当你在敲击键盘跑程序时,它其实是一边在用户的工具包里取东西,一边随时准备切换成内核的专属工作台。
探索至此,我们对 Linux 0.11 核心的三大模块(进程、内存、堆栈)已经完成了深度遍历。这些底层逻辑,正是现代所有计算机操作系统的基础。希望这篇解析,能让你在面对未来更复杂的现代架构时,依然能看到那最初的、质朴而硬核的智慧光芒。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐


所有评论(0)