第一次把自写的引导扇区烧进镜像、敲下运行命令结果虚拟机窗口一闪就重置循环往复屏幕上一个字符都没留下——这个场景我经历过不止一次。手写操作系统的第二篇卡的就是这道坎计算机启动。前面那些搭工具链、写 Makefile 的准备工作都还好说真正让人摸不着头脑的是从上电到你的第一行汇编被执行之间究竟发生了什么。这篇文章要解决的问题很具体——搞清楚 x86 机器加电后的完整交接流程写出一个真正能被固件加载并执行的 512 字节引导扇区然后把它从 16 位实模式推进到 32 位保护模式。适合已经能编译出二进制文件、但对着黑屏不知道该看哪儿的人也适合想弄明白操作系统第一条指令到底在哪的读者。我会把每一步的动机讲清楚再把踩过的坑摊开来说因为这些坑基本没有报错信息全靠推理。1. 上电之后处理器其实一无所知很多人写启动代码时的心理模型是我的代码就是起点这个模型是错的而且错得离谱。真正的起点在物理内存顶端的一个固定地址你的代码只是被接力传过来的第四五棒。把这个链条理清楚后面所有的调试都能找到参照系。1.1 复位向量固化在地址里的一次长跳转x86 处理器在收到复位信号后内部寄存器会被置成一组固定值代码段基址CS 0xFFFF指令指针IP 0x0000。实模式下的物理地址算法是段基址 × 16 偏移也就是0xFFFF0距离 1MB 边界只剩 16 个字节。这 16 个字节放不下一段引导程序所以主板固件在这里放了一条跳转指令把控制流引到自己的初始化代码去。这个设计有个很有意思的细节0xFFFF0落在 1MB 之内但只差一点点就出去了。早期的机器在这里放的是 ROM地址线的高位被忽略的问题在这个位置特别敏感这也是后来 A20 门控电路存在的原因之一。不过对写引导扇区的人来说只需要记住一件事——你的代码不是第一个跑的前面已经有一大堆固件代码跑过了而且它们留下了很多约定俗成的状态比如启动设备号被放在DL寄存器里。我在第一次调试时因为不清楚这一点用了int 0x13去读盘却莫名其妙失败后来才发现固件传过来的DL值被我自己的代码覆盖了。这类隐式入参在裸机开发里非常常见养成习惯进入自己的代码第一件事把关键寄存器先备份或者立即用掉。1.2 固件做的三件事以及它没做的事从0xFFFF0开始的那段固件代码通常叫 BIOS 或 UEFI 兼容层会依次完成设备自检、建立中断向量表、初始化显示和磁盘控制器。做完这些之后它要决定把控制权交给谁这个决定依据的是启动顺序光驱、硬盘、U 盘、网络逐个尝试。对每一个候选设备固件做的事情非常机械把该设备的第一个扇区512 字节读进内存0x7C00然后检查这 512 字节的最后两个字节是不是0x55 0xAA。是就跳过去执行不是就换下一个设备。整个过程没有任何文件系统概念没有文件名没有长度信息就是第一扇区 魔数。这也是为什么引导扇区必须严格 512 字节——它不是习惯是硬性契约。固件没有做的事同样重要它没有开启保护模式没有建立分页没有初始化你的栈没有帮你把段寄存器清零也没有保证DS和ES指向你想要的地方。你在引导扇区里拿到的是一台能用但脏的机器所有寄存器状态都是上一个使用者留下的。所以我的习惯是开头三行固定动作关中断、清段寄存器、设栈指针一个都不能省。2. 512 字节的契约地址、魔数与镜像引导扇区这个概念的约束比大多数人想的要严。不是大概 512 字节而是必须正好 512 字节魔数必须在最后两个字节并且字节序是低字节在前。这三条里任何一条不满足现象都是什么都没发生或者虚拟机不停重启因为你根本拿不到任何反馈。2.1 为什么加载地址偏偏是 0x7C000x7C00这个数字经常被当成魔法常数背下来但它有明确来历。取0x8000作为上界往前留出 512 字节就是0x7C00。选0x8000当上界的原因是早期设计里 32KB 以下的内存区域要留给固件自己的数据和中断向量表而从0x8000往上是一块相对干净、且足够放一个引导程序外加它可能加载的若干扇区的空间。这个约定从早期的个人计算机一直沿用到现在成了事实标准。这意味着两件事。第一你的代码在链接/汇编时必须以0x7C00为起点计算地址汇编器里对应的是ORG 0x7C00伪指令。第二0x7C00往下是中断向量表和固件数据区往上是你自己可以用的空间但要注意别撞到0xA0000开始的显存映射区。提示ORG只影响标号地址的计算不会真的把代码搬到那个地址。加载动作是固件做的你写的ORG是在告诉汇编器假设我被放在这里请按这个基址算我所有标号的地址。忘记写ORG会发生什么标号全都从 0 开始算于是你mov si, msg拿到的是一个接近 0 的地址而字符串实际在0x7C00附近。执行结果通常是读到一片零或者垃圾打印出一堆奇怪符号或者干脆卡死。这个 bug 极其常见因为它不会报错汇编器完全不管你。2.2 补齐、魔数与镜像写入的正确姿势下面是我用了很久的一段骨架可以当模板抄BITS 16 ORG 0x7C00 start: cli ; 关中断此时中断向量表还没被我们接管 xor ax, ax mov ds, ax mov es, ax mov ss, ax mov sp, 0x7C00 ; 栈往下长从 0x7C00 开始是安全的 sti mov si, msg call print_str hang: hlt jmp hang print_str: pusha .next: lodsb test al, al jz .done mov ah, 0x0E ; 电传打字输出 mov bh, 0x00 mov bl, 0x07 int 0x10 jmp .next .done: popa ret msg db Boot sector alive., 13, 10, 0 times 510 - ($ - $$) db 0 ; 补零到 510 字节 dw 0xAA55 ; 最后两字节魔数times 510 - ($ - $$) db 0这一行的含义值得说清楚$$是当前段起始偏移$是当前偏移两者相减就是已经生成了多少字节。用 510 减去它得到还差多少字节然后补零。如果算出来是负数说明代码超过 510 字节了汇编器会报错——这是好事至少你知道超了。然后是dw 0xAA55。注意这里写的是0xAA55而不是0x55AA因为dw按小端序写入低字节0x55先落盘高字节0xAA后落盘最终文件里就是55 AA。这个顺序极容易搞反写成dw 0x55AA的话文件里是AA 55固件校验失败你会看到没有可启动设备或者直接跳过。写入镜像我一般这么做nasm -f bin boot.asm -o boot.bin stat -c%s boot.bin # 必须是 512 dd if/dev/zero ofdisk.img bs512 count2880 dd ifboot.bin ofdisk.img bs512 count1 convnotruncconvnotrunc别漏。不加的话dd会先把输出文件截断到写入长度整个镜像只剩 512 字节虽然这个例子里看不出问题但等你后面要往镜像里塞第二、第三个扇区时就会出现神秘的数据丢失。至于-fda和-drive两种启动参数的区别我在第 5 章会专门讲因为这里也有坑。3. 让引导扇区真正开口说话代码能跑起来和你知道它能跑起来是两回事。在裸机环境下最廉价也最可靠的反馈手段就是屏幕输出。这一步做通了后面所有调试都有了眼睛。这里涉及一个取舍是直接用固件中断打印还是自己写显存我的建议是先用中断因为这个阶段你连栈都没完全确认直接操作显存容易引入额外变量。3.1 用固件中断打印为什么选 int 0x10 而不是写显存int 0x10的AH0x0E功能是电传打字输出把AL里的字符打到当前光标位置然后光标后移遇到回车换行符会自动处理。它的好处是固件已经帮你处理了模式设置、光标管理、滚屏这些琐事你只要一个字符一个字符喂进去就行。相比之下直接往0xB8000写显存每字符两字节低字节 ASCII高字节属性虽然更快、更高级但在实模式下你需要确认显存段地址、字符属性格式、以及当前是不是文本模式。对第一个验证程序来说这些额外变量都是干扰项。同样出于减少变量的考虑我在print_str里用了lodsb它把DS:SI指向的字节读进AL并自动SI。用它的前提是DS已经设好——这也解释了为什么开头必须先mov ds, ax把DS清零。如果DS是固件留下的某个随机值lodsb读的就是完全不相干的内存。pusha/popa这对指令是为了保护调用者的寄存器现场。不写的话返回值回来AX、SI全变了在小程序里可能看不出来等你后面有多个调用点时就会出问题。这类看起来多余的防御性代码在裸机开发里一点都不多余。3.2 清屏与光标控制一次小实验带来的信心验证完打印我建议顺手加一个清屏因为它能同时验证两件事中断调用正常以及AX里高字节参数传递正常。clear_screen: pusha mov ax, 0x0003 ; AH0x00 设置显示模式, AL0x03 80x25 彩色文本 int 0x10 popa retmov ax, 0x0003这一句同时设置了两个参数AH0是设置显示模式功能号AL3是模式号。这种把两个参数塞进一个 16 位寄存器的做法在实模式固件接口里遍地都是初看很别扭习惯了之后反而觉得省事。实测下来先清屏再打印能立刻区分出三种状态屏幕干净且有字完全正常、屏幕没清但有字模式设置失败但输出可用、屏幕一片黑根本没执行到。这三种状态的排查方向完全不同所以这个看似多余的步骤其实是个很划算的诊断工具。3.3 从汇编到可启动镜像的完整链路把上面的东西串起来一条完整的构建链路大概是这样boot.bin: boot.asm nasm -f bin boot.asm -o boot.bin disk.img: boot.bin dd if/dev/zero ofdisk.img bs512 count2880 dd ifboot.bin ofdisk.img bs512 count1 convnotrunc run: disk.img qemu-system-i386 -drive formatraw,filedisk.img debug: disk.img qemu-system-i386 -drive formatraw,filedisk.img -s -S-s -S这两个参数值得记住-S让虚拟机启动后先暂停不执行任何指令-s在 1234 端口开一个调试服务。配合gdb你可以连上去单步执行甚至在0x7C00下断点gdb -ex target remote :1234 \ -ex set architecture i8086 \ -ex break *0x7C00 \ -ex continue这里有个小细节默认gdb认为你在调试 64 位程序地址和寄存器宽度都不对所以必须先set architecture i8086。我第一次用的时候没设x/10i 0x7C00出来的反汇编完全是乱的还以为是编译器出了问题折腾了半小时。4. 从实模式跨进保护模式三道必须过的关实模式的限制很硬只能寻址 1MB段基址必须是 16 的倍数没有内存保护任何代码都能改任何地方。要写一个像样的操作系统早晚得进保护模式。这一步是启动流程里最容易翻车的地方因为失败的表现往往是重启或者静默死机没有异常信息。4.1 实模式的牢笼到底困住了什么先明确约束。实模式下物理地址 段 × 16 偏移段寄存器 16 位最大段基址0xFFFF0加上 16 位偏移理论上能到0x10FFEF但地址线只有 20 根超出的部分回绕。所以可用内存实际上是 1MB其中还有一部分被显存和固件占着。段基址必须是 16 的倍数这一条导致你没法让一个段从任意地址开始。想访问一个不对齐的地址只能调整偏移而偏移是 16 位的最大 64KB于是段 偏移的组合方式变得很别扭。写大程序的时候这种别扭会变成实实在在的负担。保护模式把这些限制全拆了段基址变成 32 位由描述符表提供段界限可以到 4GB还多了特权级和读写执行权限。代价是你必须先建一张表告诉处理器我要哪些段、每个段什么属性然后打开一个开关。这张表叫全局描述符表GDT。4.2 GDT 到底在描述什么GDT 就是一段内存里面按顺序排着若干个 8 字节的描述符。第一个必须是全零叫空描述符处理器要求索引 0 不可用用来捕获忘记设置段寄存器的错误。后面每个描述符描述一个段。一个描述符 8 字节拆开看是这样字段位宽作用段限界 15:016段大小低 16 位基址 15:016段起始地址低 16 位基址 23:168段起始地址中 8 位访问字节8类型、特权级、存在位段限界 19:16 标志8限界高 4 位 粒度、位宽等标志基址 31:248段起始地址高 8 位这种把一个 32 位值劈成三段塞进不同位置的布局是历史包袱造成的手写的时候很容易算错。实际做法是用汇编伪指令拼装让汇编器去算位gdt_start: dq 0x0000000000000000 ; 空描述符 gdt_code: ; 代码段基址 0限界 4GB可读可执行 dw 0xFFFF dw 0x0000 db 0x00 db 0x9A db 0xCF db 0x00 gdt_data: ; 数据段基址 0限界 4GB可读写 dw 0xFFFF dw 0x0000 db 0x00 db 0x92 db 0xCF db 0x00 gdt_end: gdt_desc: dw gdt_end - gdt_start - 1 ; 限界 表长 - 1 dd gdt_start ; 表基址访问字节里的0x9A和0x92值得拆开看。0x9A二进制是1001 1010最高位P1表示段在内存中接下来两位DPL00是最低特权级S1表示这是代码或数据段不是系统段低四位1010中1是代码段标志0表示非一致代码段1表示可读0表示不是已访问。0x92是1001 0010低四位0010里高位的0表示数据段1表示可写0表示向上扩展不是向下扩展的栈段。0xCF二进制是1100 1111G1开启粒度段限界单位从字节变成 4KBD/B1表示 32 位默认操作数宽度L0表示不是 64 位代码段低四位1111是限界高 4 位。所以限界0xFFFFF乘以 4KB 等于 4GB正好覆盖整个 32 位地址空间。4.3 A20 门控与 CR0.PE两个必须按顺序按下的开关在进保护模式前还有一道很容易被忽略的门A20 地址线。前面提过实模式地址回绕的问题为了让老软件在新技术上还能跑硬件上加了个门控默认关闭第 21 根地址线。不开它就算你进了保护模式访问超过 1MB 的地址也会回绕到低地址症状是写进去和读出来的数据不一致非常隐蔽。开 A20 有几种方法从慢到快走键盘控制器、走端口0x92的快速通道、走固件中断。我一般用端口0x92因为它代码最短in al, 0x92 or al, 0x02 ; bit1 置 1 打开 A20 and al, 0xFE ; bit0 是快速复位位必须清掉 out 0x92, al这里and al, 0xFE那一步是血泪教训。端口0x92的 bit0 在某些主板上是快速复位控制位如果读出来的值里这一位恰好是 1你直接or之后写回去机器立刻重启而且重启后再跑同样代码再重启形成无限循环。第一次遇到这个现象我以为是代码写错了单步跟了半天才发现是硬件复位。不管这台上机器的 bit0 是什么状态写之前清掉它成本为零。A20 开完之后才是重头戏加载 GDT、设置CR0.PE、远跳转。顺序不能乱代码长这样cli lgdt [gdt_desc] mov eax, cr0 or eax, 0x01 ; PE 位置 1 mov cr0, eax jmp 0x08:pm_entry ; 远跳转刷新 CS BITS 32 pm_entry: mov ax, 0x10 ; 数据段选择子 mov ds, ax mov es, ax mov fs, ax mov gs, ax mov ss, ax mov esp, 0x7C00 ; 到这里才真正处于 32 位平坦模式jmp 0x08:pm_entry这一句是整个流程的枢纽。CR0.PE置位的那一刻处理器已经处于保护模式但CS里还装着实模式的段值指令预取队列里可能还有按 16 位解码的指令。必须用一个远跳转把CS换成新的选择子同时清空流水线。这个远跳转的偏移部分在BITS 32之前是 16 位语义之后就变成 32 位语义。选择子0x08的意思是索引 10x08 3 1对应gdt_code表指示位TI0用 GDT请求特权级RPL0。0x10则是索引 2对应gdt_data。搞清这个换算关系就不会在段寄存器赋值上出错。4.4 进入保护模式后世界变了成功进入保护模式之后有几件事和实模式完全不同必须马上意识到。第一int 0x10不能用了。保护模式下中断处理走的是中断描述符表IDT而IDT寄存器此刻是无效的。任何中断或异常都会导致处理器尝试读一个非法地址通常表现为三重复位连续三次异常后自动重启。所以在lgdt和置位CR0.PE之间cli是必须的进入保护模式后也是先搭好 IDT 再sti。第二直接用显存取代表情输出是可行的因为平坦段覆盖了0xB8000pm_print: mov esi, msg32 mov edi, 0xB8000 mov ah, 0x0F ; 黑底白字 .loop: lodsb test al, al jz .done stosw ; 写 AL 到 [EDI], AH 到 [EDI1], EDI 2 jmp .loop .done: hlt jmp .done msg32 db Now running in 32-bit protected mode., 0stosw一次写两个字节正好是字符 属性的布局。lodsb用的是DS:ESI因为DS已经被设成平坦数据段而标号地址是按ORG 0x7C00算的线性地址两者一致所以能正确读到数据。这个一致性依赖一个前提数据段基址是 0。如果哪天你把数据段基址改成别的值那esi就得相应减去基址否则读错地方。第三栈的位置要重新审视。实模式我设的是SP0x7C0016 位保护模式要设的是ESP32 位。mov esp, 0x7C00让栈从引导扇区开始往下长这会覆盖掉引导扇区自身的代码。在示例程序里因为之后不再执行那段代码所以没事但真要往下写必须把栈挪到安全区域比如0x7C00以下、避开中断向量表的一段空闲区或者干脆自己划一块缓冲区。5. 启动代码的调试没有报错的失败怎么排查裸机开发和写应用最大的区别是没有错误信息。段错误、越界、非法指令在操作系统里会给你一个信号或者一段日志在这儿全都是没反应。所以排查必须靠系统性的二分和推测。5.1 先分清三种失败现象我把遇到过的失败归成三类每类的排查路径完全不同现象可能的环节首要怀疑对象虚拟机瞬间重启、循环保护模式切换、三重复位CR0.PE之后的异常、未设 IDT 就开中断停在原地、屏幕全黑无字引导扇区没被执行魔数、镜像格式、启动参数有输出但内容乱、卡在奇怪位置代码执行了但地址算错漏ORG、段寄存器未初始化、栈覆盖先用一句话定位属于哪一类能省掉大量时间。第二类其实最好排——在0x7C00第一条指令下断点如果断点命中说明固件已经加载了你的代码问题在后面如果根本没命中问题在镜像和启动参数。5.2 用模拟器做取证qemu-system-i386 -d int -D qemu.log会把每次中断和异常记录下来。三重复位在日志里非常明显能看到异常号连续出现。另一个好用的是-no-reboot -no-shutdown让虚拟机在出错时停下而不是反复重启这样你能用调试器连上去看现场。如果怀疑是镜像问题最省事的验证方法是把镜像的前 512 字节 dump 出来看看最后两个字节xxd -s 510 -l 2 disk.img输出应该是55aa。如果不是前面的努力全白费。这个检查我加进了 Makefile 里每次构建都跑一遍十秒钟的事能省掉半小时的困惑。5.3 几个真实踩过的坑坑一镜像不是 512 的整数倍。用dd创建镜像时如果忘了预填充零镜像文件可能只有 512 字节某些启动参数下固件会拒绝加载。我现在的做法是固定先创建一个 1.44MB 的标准软盘镜像再把引导扇区覆盖进去长度永远合规。坑二-fda和-drive的行为差异。-fda直接把镜像当软盘设备固件会按软盘规则读第一扇区而-drive formatraw,file...是把镜像当硬盘。硬盘路径下固件读的是 MBR第一扇区如果这 512 字节能过魔数校验也能启动但如果后续要用int 0x13的扩展读盘功能AH0x42硬盘和软盘的参数结构不一样很容易读错。我建议调试阶段统一用软盘参数简单直接。坑三保护模式代码里调用了实模式中断。这个坑我踩得最深——切换成功后代码里残留了一句int 0x10想打印一行提示结果机器静默重启。原因是 IDT 未建立中断向量指向垃圾。改成直接写显存后立刻正常。坑四GDT 的基址字段没填对。GDT 描述符里有一个 32 位的dd gdt_start这个值是按ORG 0x7C00计算出的线性地址。如果你的代码被加载到别的地方或者你先切到了别的段再加载 GDT这个地址就对不上lgdt成功但后续段加载全部出错。验证方法是在lgdt之后用调试器读一下GDTR的值和你预期对比。坑五hlt之后没有循环。中断关闭的情况下hlt会让机器永久休眠看起来像死机。调试阶段我习惯写hang: hlt紧跟jmp hang虽然hlt本身不会返回但中断开启时它会醒加个循环更稳妥。6. 让启动代码好改文件组织与后续扩展的接口引导扇区 512 字节的限制很快就会撞上。想在启动流程里做更多事读更多扇区、探测内存、设置视频模式必须把代码拆分。这一步不做后面每加一个功能都要重新算空间痛苦指数极高。6.1 把 512 字节当装货员而不是仓库我的做法是让引导扇区只做三件事初始化环境、把后续的扇区读到内存、跳到那里执行。真正的逻辑放在第二、第三个扇区里那些地方不受 512 字节限制。读盘用int 0x13的AH0x02CHS 模式就够了参数含义AL读几个扇区CH柱面号CL扇区号从 1 开始DH磁头号DL驱动器号ES:BX目标缓冲区。load_next: mov ax, 0x0000 mov es, ax mov bx, 0x8000 ; 读到 0x8000避免和引导扇区重叠 mov ah, 0x02 mov al, 1 ; 读 1 个扇区 mov ch, 0 ; 柱面 0 mov cl, 2 ; 第 2 个扇区1 是引导扇区自己 mov dh, 0 ; 磁头 0 int 0x13 jc disk_error ; 进位标志置位表示失败 jmp 0x0000:0x8000jc disk_error这一步千万别省。磁盘读取失败是常态参数错、设备没准备好、超出范围不检查的话就跳到一片零里执行现象是机器卡住。我现在的习惯是任何固件调用后都检查返回状态出错了至少打一个字符提示比静默死机好一百倍。0x13调用失败后重试也是常规做法因为首次读取时驱动器马达可能还没起来。标准做法是重试三次每次之间复位驱动器AH0x00。这段代码很短但能显著提升在不同模拟器上的兼容性。6.2 从汇编交棒给高级语言要准备什么大多数人写到这一步就想用 C 了毕竟汇编写业务逻辑太低效。交接需要满足几个条件缺一个就会崩。第一栈必须可用且足够大。C 函数调用、局部变量、返回地址全依赖栈。我会在低内存里划一块区域比如把ESP设为0x90000并确保这块区域不和代码、数据重叠。第二需要平坦的数据段。C 编译器生成的代码默认假设平坦地址空间所以 GDT 里的数据段和代码段基址都得是 0限界 4GB。这也是前面我把描述符全设成 0 基址、0xFFFFF限界的原因。第三入口函数签名要干净。不要指望从汇编调 C 时还能拿到固件留下的寄存器编译器可能在任何时候覆盖它们。需要传递的信息比如内存布局、启动设备号应该先存到约定的内存地址C 侧再从那里读。第四编译参数要选对。用-ffreestanding、-fno-pic、-fno-stack-protector这几个开关关掉所有依赖操作系统服务的特性。不加的话编译器可能生成对memset、__stack_chk_fail之类符号的引用链接时直接报一堆未定义符号——这个错误信息倒是很友好至少告诉你缺什么。C 代码编译成目标文件后用链接脚本把入口地址固定到加载位置这一步和ORG是同一个道理只是换成了链接器的语言。第一次配链接脚本容易把OUTPUT_FORMAT和OUTPUT_ARCH写错症状是链接出来的二进制格式不对加载后第一个字节就被当成指令跑飞。6.3 我个人在启动阶段的几条习惯写到这里分享几个形成肌肉记忆的习惯都和少走弯路有关。一是每加一个功能就单独验证一次。GDT 加完马上跑一遍能打印就继续A20 开完单独验证一次读一块超过 1MB 的内存并写回比较保护模式切完先什么都不做只打印。分步走比一口气写完再调要快得多因为出错时你只面对一个新增变量。二是保留一份最小可运行版本。我会把能正常打印的那一版单独存档任何改动出问题就退回去从已知可用状态重新增量。裸机调试最怕的就是在一个已经不对的基础上继续改。三是给关键位置加标记位输出。比如在 GDT 加载前打一个字符在保护模式入口打另一个字符。屏幕上出现几个字符就能精确定位代码执行到了哪一步。这比用调试器单步快尤其是在只需要判断死在哪的时候。四是用标准长度的镜像。不管实际用多少镜像始终是完整的软盘大小。这避免了大量和固件读取范围有关的隐性问题代价只是多占一点空间完全不心疼。还有一个我吃过亏的地方修改代码后一定要确认构建产物真的更新了。有一次 Makefile 的依赖写漏了改完汇编直接运行跑的还是旧镜像我对着完全对不上的现象排查了十几分钟。现在我的 Makefile 里加了一条强制删除目标文件的动作宁可多编译一次也不要在假的现场上推理。启动这一环打通之后后面能做的事就多了——内存探测、保护模式下的中断处理、简单的屏幕驱动都是在这个基础上往下堆。但根基就是这几百字节一个正确的魔数、一个正确计算的地址、一个正确的跳转。每一处都不能想当然。
阅读完成 · 觉得有帮助?