当前位置: 首页 > news >正文

区别x86 OS, 我们跨进长模式!:别只抄那段汇编——顺序、页表与那些三重故障

区别x86 OS, 我们跨进长模式!:别只抄那段汇编——顺序、页表与那些三重故障

之前我们把机器拉进了 32 位保护模式,打了个P到 debugcon 就停了。可 Cinux 是个 x86_64 系统,真正要跑的是 64 位。这一章,我们就在那张 32 位 PM 的地基上,搭一套临时分页、按 Intel 规定的固定顺序拨开几个开关,再用一句远跳跨进 64 位长模式——跨过去之后,debugcon 会再吐一个L

如果您是想尝试 Cinux,并对一些驱动、前沿细节的实现感兴趣的朋友,请移步到下面的仓库:
https://github.com/Awesome-Embedded-Learning-Studio/Cinux

如果您对手写一个现代 C++ 操作系统感兴趣的朋友,请到这里:
https://github.com/Awesome-Embedded-Learning-Studio/Cinux-Book

或者,直接访问文档站开始阅读:https://awesome-embedded-learning-studio.github.io/Cinux-Book/

如果上面的内容,对您的学习和实际的开发哪怕有一丝帮助,都是笔者极大的荣幸!喜欢的话,麻烦小小的赏一个 ⭐(QAQ)。自己的知识仍不精湛,文章必然还有很多错误,还请各位大佬批评斧正!

这一章我们要点亮什么

pm_entry打完'P'之后,我们不再hlt,而是接着干两件事,把 CPU 从 32 位 PM 推进 64 位长模式:

pm_entry (32 位 PM) └─▶ setup_page_tables() # 在 0x1000/0x2000/0x3000 搭临时恒等映射 └─▶ enter_long_mode() ├─ CR3 = 0x1000 # 装载 PML4 基址 ├─ CR4 |= PAE # 开物理地址扩展 ├─ EFER |= LME # 开"长模式使能" ├─ lgdt gdt64_ptr # 换一张带 64 位段的 GDT ├─ CR0 |= PG # 开分页——这一拍长模式才真正生效 └─ ljmp $0x18, $long_mode_entry # 远跳到 64 位代码段 └─▶ long_mode_entry (.code64) ├─ 数据段 = 0x20、rsp = 0x90000 ├─ outb 'L', $0xE9 # debugcon 打 'L' └─ hlt 循环

完成后,build/debug.log里会依次出现P(002 进 PM 时打的)和L(本章进长模式时打的)——连起来就是PL。看到L,就证明 Cinux 已经是货真价实的 64 位 CPU 模式了。

为什么现在需要它

你可能觉得 32 位 PM 已经够用了,为什么要费劲进 64 位?因为后面我们要写的是一个真正的 x86_64 内核:它要用 64 位寄存器、要寻址远超 4GB 的内存、要用syscall/sysret这套 64 位专属的快速系统调用。这些在 32 位 PM 里统统做不到。

但进长模式有个硬门槛:长模式强制要求分页开启。和 32 位 PM 不同(PM 下分页是可选的),长模式必须建立在"分页已开 + PAE 已开 + 四级页表"的基础上。原因在于,长模式本质上是"在 PAE 四级页表之上加了一层"——CPU 一旦进入长模式,所有地址翻译都得走 PML4→PDPT→PD→PT 这套四级结构,没有分页它根本没法翻译地址。

所以这一章的主线其实就是:先搭一套刚好够用的临时分页,再按顺序拨开关。这套分页我们故意做得极简——只恒等映射前 8MB,够 bootloader 自己和接下来要加载的内核跑起来就行。真正的物理内存管理(PMM)和虚拟内存管理(VMM)是 015/016 的事,现在不碰。

外部依据:Intel SDM Vol.3A §4.1(四级分页)、§4.3(2MB 大页)、§4.5(PAE)、§11.8.2(EFER 与 LME)、§9.8(切换到长模式的固定序列)。

设计图

先看这套临时分页长什么样。我们用四级页表里最粗粒度的2MB 大页,只填三张表、映射前 8MB:

地址 表 作用 0x1000 PML4 PML4[0] → 指向 PDPT 0x2000 PDPT PDPT[0] → 指向 PD 0x3000 PD PD[0..3] → 4 个 2MB 大页,恒等映射 0~8MB

为什么三张表就够?因为我们用 2MB 大页,到 PD 这一层就终止了(大页标志位 PS=1 表示"这一项是页,不用再往下查 PT")。一个 PD 项映射 2MB,4 个就盖 8MB。恒等映射的意思是"虚拟地址 = 物理地址"——我们的 bootloader 和内核都在低地址跑,这种最省事的映射刚好够用。

再看进入长模式的状态机,顺序是 Intel 定死的,调换一个就三重故障:

32 位 PM │ CR3 = 0x1000 # 装载 PML4 基址(让 CPU 知道页表在哪) │ CR4 |= PAE (bit 5) # 开物理地址扩展(长模式的前置条件) │ EFER |= LME (bit 8) # 开"长模式使能"——但此刻还没生效 │ lgdt gdt64_ptr # 换上带 64 位代码段的 GDT ▼ CR0 |= PG (bit 31) # ★ 开分页:LME 此刻"激活",长模式真正生效 │ ▼ ljmp $0x18, $long_mode_entry # 远跳到 64 位代码段,刷新 CS │ ▼ 长模式(64 位)

EFER.LME设了之后并不会立刻生效——它要等到CR0.PG被置位的那一拍才真正激活(因为长模式绑死在分页上)。这个"先 LME 后 PG"的顺序是 Intel 的规定,反了就会触发 #GP。

代码路线

源码主要在新增的 long_mode.S(setup_page_tablesenter_long_mode)以及 stage2.S 末尾接上的.code64 long_mode_entry和扩展 GDT。

1. 为什么长模式必须先有分页

(上面"为什么现在需要它"已经讲了原因,这里补一个实操上的关键点。)我们待会儿要lgdt、要远跳、要读内存里的页表本身——这些地址翻译,在分页开启后全部要走我们搭的这套页表。所以页表必须先搭好、并且正确,否则CR0.PG一置位,CPU 连下一条指令的地址都翻译不出来,当场三重故障。这就是为什么setup_page_tables是第一件事,而且要做成恒等映射:让"搭页表的代码所在的地址"在分页前后都指向同一处,避免"开了分页反而找不到自己"的尴尬。

2. setup_page_tables:三张表 + 4 个 2MB 大页

long_mode.S 里,先把三张表清零(页表项未用的位必须是 0,否则 CPU 当成有效项去查,会出问题):

setup_page_tables: cld # 清零 PML4(0x1000)/ PDPT(0x2000)/ PD(0x3000),各 1024 个 dword = 4096 字节 movl $0x1000, %edi xorl %eax, %eax movl $1024, %ecx rep stosl # ... 对 0x2000、0x3000 同样再来两遍

清零靠rep stosl——ecx个 dword、从edi起逐个写eax(0),一个循环写完一整页。这里cld先把方向标志清零,保证stosl是地址递增方向(否则往低地址写,直接写飞)。

然后串起三级指针,再填大页:

# PML4[0] → PDPT,带 present+writable movl $0x2000, %eax orl $0x03, %eax # 0x03 = Present | Writable movl %eax, 0x1000 # 写进 PML4[0] # PDPT[0] → PD movl $0x3000, %eax orl $0x03, %eax movl %eax, 0x2000 # 写进 PDPT[0] # PD[0..3]:4 个 2MB 大页,恒等映射 0~8MB movl $0x3000, %edi movl $4, %ecx xorl %eax, %eax # i = 0 1: movl %eax, %edx shll $21, %edx # 物理基址 = i << 21(每页 2MB = 0x200000) orl $0x83, %edx # 0x83 = Present | Writable | Large(PS 位) movl %edx, (%edi) addl $8, %edi # 下一项(每项 8 字节) incl %eax loop 1b ret

这里每一层的细节:

  • 0x03 = Present(0x01) | Writable(0x02):中间层(PML4/PDPT)的项指向下一层表,只需要这两个权限。
  • 0x83 = Present | Writable | Large(0x80):Large位(页表项里的 PS 位,bit 7)是关键——它告诉 CPU"这一项不是指向下一层 PT 的指针,它本身就是一个大页"。置了它,CPU 到 PD 这层就停下,直接用这一项的基址当 2MB 页的起始。没置 PS 位,CPU 会继续去查一个根本不存在的 PT,读到 0,触发缺页。
  • i << 21:2MB =0x200000=1 << 21。第i个大页的物理基址就是i << 21。恒等映射下,虚拟基址也是i << 21,所以前 8MB 虚拟地址 = 物理地址。

每个页表项 8 字节(64 位),但因为我们只用到低 32 位(地址都在 4GB 以内),代码里用 32 位写(movl)只写了低 4 字节,高 4 字节是前面清零留下的 0——对低地址映射来说够了。

3. enter_long_mode:顺序即一切

long_mode.S 的enter_long_mode就是上面设计图里那串状态机的直译,顺序一个都不能动:

enter_long_mode: movl $0x1000, %eax movl %eax, %cr3 # ① CR3 = PML4 基址 movl %cr4, %eax orl $0x20, %eax # CR4.PAE = bit 5 movl %eax, %cr4 # ② 开 PAE movl $0xC0000080, %ecx # EFER 的 MSR 地址 rdmsr # 读 EFER 到 edx:eax orl $0x100, %eax # EFER.LME = bit 8 wrmsr # ③ 写回 EFER(此刻 LME 还没生效) lgdt gdt64_ptr # ④ 换带 64 位段的 GDT movl %cr0, %eax orl $0x80000001, %eax # CR0.PG(bit 31) | CR0.PE(bit 0) movl %eax, %cr0 # ⑤ ★ 开分页:LME 激活,长模式生效 ljmp $0x18, $long_mode_entry # ⑥ 远跳到 64 位代码段

这里有四处必须留意,逐个过一遍。EFER是个 MSR(Model-Specific Register),地址0xC0000080,不能用mov,得用rdmsr/wrmsr——读时结果落在edx:eax、写时也从edx:eax,操作前把地址放进ecx,而LME是 bit 8,即0x100。顺序则是死的:PAE(CR4)必须在EFER.LME之前、EFER.LME必须在CR0.PG之前,CR0.PG置位那一拍长模式才真正激活,这就是 Intel 的固定序列(详见 SDM §9.8.1.1)。CR0 |= 0x80000001这步同时置 PG(bit 31)和保留 PE(bit 0),注意用orl而非movl——CR0里还有别的控制位(比如 cache 相关),直接movl $...会把它们清掉,这和 002 置 PE 时用orb是一个道理。最后还是那条远跳:CR0.PG置位后 CPU 已在长模式,可CS还指向 32 位段,和 002 进 PM 时一样,必须一条远跳带着新的 64 位代码段选择子(0x18)去刷新CS,而紧跟的.code64则告诉汇编器从long_mode_entry起按 64 位编码。

4. 扩展 GDT:64 位代码段的关键是 L 位

长模式需要一个L 位 = 1的代码段描述符。我们在 stage2.S 的 GDT 里,在 002 那三项(null/code32/data32)后面又加了两项:

gdt_code64: .quad 0x00AF9A000000FFFF # 64 位代码段:L=1, D=0 gdt_data64: .quad 0x008F92000000FFFF # 64 位数据段

0x00AF9A000000FFFF按小端拆成字节看:FF FF 00 00 00 9A AF 00。关键的两个字节:

  • access = 0x9A(1001 1010):P=1、DPL=0、S=1、code/exec/read——和 32 位代码段一样。
  • byte[6] = 0xAF:高 4 位是 flags1010——G=1、D/B=0、L=1。这里的L=1就是"长模式代码段"的标志;同时D=0(在 L=1 时 D 必须为 0,这是 Intel 的规定,否则触发 #GP)。低 4 位0xF是 limit 19:16。

选择子也相应扩出来:0x08/0x10还是 32 位那两个(002 已用),新增0x18= 64 位代码、0x20= 64 位数据。GDT 从 3 项变 5 项。

gdt64_ptr是给长模式 reload 用的 GDTR。这里有个 ELF 的小坑:Stage2 是按 32 位 ELF(elf_i386)链接的,如果直接用.quad gdt写 64 位 base,会触发一个 32 位 ELF 不支持的 64 位重定位。所以代码用.long gdt+.long 0两段拼出 64 位 base——GDT 在低地址,高 32 位是 0,这样既绕开了重定位,又给出了正确的 64 位基址。

还是要提醒:这张 5 项 GDT 仍是bootloader 的。后面 big kernel(010)会建它自己完整的 GDT(带 TSS、带用户段)。两者的选择子数值虽然部分重合(都有 0x08/0x10),但不是同一张表。读到这里别把它们混为一谈。

5. long_mode_entry:64 位段、64 位栈,debugcon 打 ‘L’

.code64 .global long_mode_entry long_mode_entry: movw $0x20, %ax # 64 位数据段选择子 movw %ax, %ds # ... es/fs/gs/ss 同样 movabsq $0x90000, %rsp # 64 位栈指针 movb $0x4C, %al # 'L' outb %al, $0xE9 # debugcon 打 'L' cli .lm_halt: hlt jmp .lm_halt

进了长模式,段寄存器重新刷成0x20(其实长模式下数据段的 base/limit 基本被忽略,但SS必须是有效段,否则压栈会 #GP)。rspmovabsq装一个 64 位立即数(长模式栈用 64 位rsp,不是 32 位的esp)。最后往0xE9吐一个'L'——和 002 的'P'用的是同一个 debugcon 机制。

调试现场

进长模式这一段,坑几乎全在"顺序"和"标志位"上。下面是几个真实调出来的。

症状一——CR0.PG一置位,当场三重故障重启。 最高频的原因是页表没搭对:要么某层表没清零(残留垃圾被 CPU 当有效项去查,查到 0 触发缺页),要么PD的大页项漏了Large(PS)位,CPU 继续往下一层查一个不存在的 PT,当场缺页。定位:在置CR0.PG那条设断点,x/4gx 0x3000PD[0..3]是不是0x..83(带 PS 位)的 2MB 页;x/1gx 0x1000PML4[0]是不是0x2003

症状二——置EFER.LME就崩,或CR0.PG置位时 #GP。 顺序错了。常见是把CR0.PG放在EFER.LME之前(等于在还没"请求"长模式时就开分页),或忘了先开CR4.PAE(长模式的前置)。Intel 对这条序列的检查很严:PAE 没开就置 LME、LME 没置就开 PG,都会 #GP。对着设计图的状态机核一遍顺序。

症状三——远跳进long_mode_entry后又三重故障。 多半是 64 位代码段描述符的 L/D 位错。L=1 时 D 必须为 0,0x00AF9A000000FFFF里的 flags 是0xA(1010: G=1,D=0,L=1)——写成0xC(1100: D=1,L=0)就是普通的 32 位段,远跳进去 CPU 不认它是长模式,译码错位崩掉。核一遍那个.quad的字节。

症状四——链接时报 64 位重定位错误。gdt64_ptr用了.quad gdt,而 Stage2 是 32 位 ELF。改成.long gdt; .long 0就好。这是个纯工具链问题,和 CPU 无关,但挺容易卡住第一次写的人。

验证

第一道闸还是构建。老规矩——003 没有运行时自动化测试,构建本身就是冒烟:

cmake-Bbuild-DCMAKE_BUILD_TYPE=Release-S.cmake--buildbuild -j$(nproc)

stage2.bin里现在嵌了.code64段,能产出说明汇编器接受了 16/32/64 位混合编码。

第二道闸看 debugcon。cmake --build build --target run,跑完看:

catbuild/debug.log# 期望:PL

P是 002 进 PM 时打的、L是本章进长模式时打的。两个都在,说明从实模式一路走到 64 位长模式全程没崩。少了PL、或者出现乱码,就照"调试现场"对号入座。

第三道闸用 GDB 确认模式。cmake --build build --target run-debug,另一终端:

(gdb) file build/boot/stage2 (gdb) target remote :1234 (gdb) b *long_mode_entry (gdb) c # 命中断点 = 远跳成功 (gdb) p/x $cs # 应是 0x18(64 位代码段) (gdb) monitor info registers # 或看 EFER.LMA 位、CR0.PG 位

能停在long_mode_entrycs=0x18、EFER 的 LMA(Long Mode Active)位为 1,就是实打实的 64 位。

下一站

现在 Cinux 是一个货真价实的 64 位长模式环境了:有分页、有 64 位寄存器、有一个能跑的栈。可它还停在 bootloader 里hlt——我们还没真正"启动一个内核"。长模式只是把舞台搭好,真正的主角(C++ 写的内核)还没登场。

下一章 004 · 加载 mini kernel,我们要让 bootloader 把第一个用 C++ 写的内核镜像从磁盘读进来,跳进它的入口,让真正的"内核代码"第一次跑起来。从那以后,汇编 bootloader 的使命就基本完成,接力棒交给 C++。


参考

  • Intel SDM Vol.3A — §4.1 四级分页(PML4/PDPT/PD/PT 结构)、§4.3 2MB/4MB 大页(PS 位)、§4.5 PAE、§11.8.2 EFER 与长模式使能(LME/MSR0xC0000080)、§9.8.1.1 切换到长模式的固定序列(CR3CR4.PAEEFER.LMECR0.PG→远跳)。
  • OSDev — Setting Up Long Mode(进入序列与临时恒等映射)、Page Tables(四级结构与页表项标志位)、Creating a 64-bit kernel(64 位 GDT 的 L 位要求)。
  • 本 tag 源码:long_mode.S(setup_page_tablesenter_long_mode)、stage2.S(.code64 long_mode_entry、扩展 5 项 GDT +gdt64_ptr)、CMakeLists.txt(boot_longmode对象库)。
  • 调试素材提炼自 1.md。

Intel SDM 版本说明:本卷引用的 SDM 章节号沿用较早版本编号。若按项目本地 PDF(document/reference/intel/,2023-06 版)查阅,部分内容已重排——四级分页在 §4.5、2MB 大页见 §4.5、PAE 在 §4.4、EFER 在 §2.2.1、切换到长模式在 Chapter 10。以章节标题为准,别拘泥于编号。

http://www.jsqmd.com/news/1104056/

相关文章:

  • 技术拆解:电子护照芯片数据为何绝对可信、无法篡改?
  • 三步轻松下载中小学电子课本:智慧教育平台PDF获取完整指南
  • 感觉csdn已经没办法使用了
  • Codex++ 启动 Codex 失败排查教程
  • 从XXE漏洞原理到实战:以CTF为例解析XML外部实体注入与防御
  • 【2026最新版】全网最全网络攻防教程(0基础到进阶、漏洞挖掘、CTF比赛、就业等等)
  • 在 Python 里,@staticmethod 和 @classmethod 都是放在类里面的方法,但它们绑定对象不同。
  • 5分钟解决Mac Boot Camp驱动难题:Brigadier自动化工具完整指南
  • HarmonyOS7 搜索页最容易做成半成品:历史、热词、结果页这次一次补齐
  • 吴恩达《深度学习》之看懂超参数搜索的“对数标尺”
  • B站评论采集实践:如何快速获取评论数据并接入AI分析平台
  • 移动网络用户访问异常专项:为什么移动投诉往往最多
  • 【量化实战】基于LLMCompressor一键落地vLLM部署
  • 鸿蒙操作系统是否超越安卓?
  • 网站站长每天必做的工作有哪些?
  • DeepSeek正式官宣摇人,夯!
  • 西门子罗宾康 A1A10000423.00M 高压变频器 I/O 板
  • 赛克艾威早报20260630:Oracle EBS与Apache HTTP Server曝高危漏洞,多款产品遭在野利用
  • rat与生态系统集成:如何将高性能文件查看器融入你的开发工作流
  • 当灯光“躲”进陪伴机器人:智能照明的隐藏式进化与异业合作新浪潮
  • Windows 11系统优化神器:Win11Debloat让你的电脑性能提升51%的秘密
  • 从零到一:在STM32上跑通TinyML的完整实践指南
  • 2026年AI建站平台哪个好?企业官网、SEO和GEO能力对比
  • ABAP :新语法 - REF
  • 编写自动化脚本时使用多线程技术
  • LangChain4j Guardrails:给你的 AI Service 装上输入输出双层卡口
  • Windows10上安装MySQL操作步骤
  • 纯小白零基础漏洞挖掘完整教程,从理论到实操一步到位,看完即可上手提交漏洞拿赏金
  • 论文格式改 3 遍还不合格?笔墨 AI 一键匹配院校模板,不用手动调半天
  • 多场景学术写作一站式解决方案,paperxie 智能论文写作功能拆解实测