SDL3 的 MS-DOS 移植版:DJGPP 交叉编译、DPMI 内存模型与 Sound Blaster 音频实战指南
【免费下载链接】SDLSimple DirectMedia Layer项目地址: https://gitcode.com/GitHub_Trending/sd/SDL
本文档对应仓库中的 docs/README-dos.md,讲解 SDL3 对 MS-DOS 平台的官方移植。该移植基于 DJGPP GCC 交叉编译器生成 32 位保护模式(DPMI)可执行文件,覆盖 VGA/VESA 视频、Sound Blaster 音频、协作式多线程与三类经典输入设备。读完本文,你将掌握在 Linux 上交叉编译 DOS 版 SDL3、在 DOSBox 或真实 DOS 硬件上运行,以及针对 DOS 特性(fat DS 指针、协作式调度、直接帧缓冲)编写和调优应用的全部要点。
移植概览:为什么 DOS 需要一套特殊实现
MS-DOS 是一个 16 位实模式操作系统,没有内存保护、没有内核级线程,也没有现代驱动模型。SDL3 的 DOS 移植采用 DJGPP(GNU GCC 的 DOS 移植)交叉编译,将应用与 SDL3 一起构建为32 位保护模式 DPMI 可执行文件。在仓库中,这一移植的代码分散在几个专门的目录下:
- 构建工具链:build-scripts/i586-pc-msdosdjgpp.cmake
- 核心运行时:src/core/dos/SDL_dos.c、src/core/dos/SDL_dos_scheduler.c
- 视频驱动:src/video/dos/(SDL_dosvideo.c、SDL_dosmodes.c、SDL_dosframebuffer.c、SDL_dosmouse.c、SDL_dosevents.c)
- 音频驱动:src/audio/dos/SDL_dosaudio_sb.c
由于 DOS 环境的特殊性,这套移植在内存访问、线程调度、视频刷新、音频中断等层面都与桌面平台的实现有本质差异,下面逐一展开。
交叉编译:从 Linux 构建 DOS 可执行文件
前置条件
构建 DOS 版 SDL3 需要:
- 下载并解压 DJGPP 工具链;
- 将包含
i586-pc-msdosdjgpp-gcc的目录加入PATH环境变量; - 保证系统已安装 CMake。
工具链文件会按顺序查找两个编译器:i586-pc-msdosdjgpp-gcc或i386-pc-msdosdjgpp-gcc(C++ 编译器对应-g++),找不到则直接报错终止配置。从源码看(build-scripts/i586-pc-msdosdjgpp.cmake),i586变体优先。
构建命令
cmake -S. -Bbuild -DCMAKE_TOOLCHAIN_FILE=build-scripts/i586-pc-msdosdjgpp.cmake -DCMAKE_BUILD_TYPE=Release cmake --build build工具链文件的关键行为
这个 toolchain 文件除了选择编译器,还处理了几件对 DOS 构建至关重要的事:
- 库命名约定覆盖:CMake 自带的
Platform/DOS.cmake默认采用 OpenWatcom 命名习惯(无前缀、.lib后缀),而 DJGPP 遵循 Unix/GCC 惯例(lib前缀、.a后缀,如libm.a)。工具链通过CMAKE_USER_MAKE_RULES_OVERRIDE在平台模块之后覆盖这些变量(第 5-13 行),并设置输出后缀.exe。 - 查找根路径:通过执行
gcc -print-search-dirs自动推导 DJGPP 的头文件与库文件根目录,追加到CMAKE_FIND_ROOT_PATH(第 42-68 行)。 - 搜索模式:程序(编译器本身)在宿主目录查找(
CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER),而库、头文件和包只在目标目录查找(ONLY),避免误链宿主系统的库(第 71-81 行)。
运行时环境与 DPMI 要求
DOS 可执行文件运行在保护模式下,因此必须有一个DPMI host(DOS Protected Mode Interface 宿主)负责提供实模式/保护模式切换、内存锁页等服务。移植文档明确要求:
将
CWSDPMI.EXE放在可执行文件旁边。
CWSDPMI是 DJGPP 自带的 DPMI 服务程序。在 DOSBox 中运行只需:
dosbox myapp.exeDOSBox 内置了 DPMI 支持,通常无需额外安装 CWSDPMI,但保持二者兼容无误仍是好习惯。
系统要求
| 组件 | 最低要求 |
|---|---|
| CPU | i386 或更高 |
| RAM | 4 MB |
| 视频 | VGA(256 色模式 13h) |
| 音频 | Sound Blaster |
| DPMI | CWSDPMI.exe 或兼容的 DPMI host |
更高的分辨率(640×480 及以上)需要VESA VBE 1.2+ 兼容的显卡。这是硬性门槛:视频驱动在初始化时通过 VBE 中断(0x10,功能0x4F00查询 VESA 信息)探测显卡能力,不满足要求就返回错误。从 src/video/dos/SDL_dosvideo.c 可以看到,驱动创建时首先调用DOSVESA_SupportsVESA(),失败则直接返回 NULL,SDL 会以清晰错误提示退出。
内存模型:fat DS nearptr 技巧
DOS 移植的 32 位保护模式程序无法直接访问低于 1 MB 的物理地址(VGA 帧缓冲、DMA 缓冲都位于实模式地址空间)。解决方案是 DJGPP 的"fat DS" nearptr 技巧:调用__djgpp_nearptr_enable()后,DS 段寄存器被设置为"扁平"覆盖整个 4 GB 地址空间,物理地址可以直接当作 C 指针使用,无需手工处理段描述符。
这套机制在 SDL 内部通过DOS_PhysicalToLinear()封装(见 src/core/dos/SDL_dos.c 等调用),被用于:
- 直接访问 VESA 线性帧缓冲(LFB);
- 访问常规内存中分配的 DMA 缓冲;
- 访问实模式中断返回的远指针字符串(
DOS_GetFarPtrCString通过_farpeekb逐字节读出)。
关键注意事项:谁负责启用 fat DS
SDL 在 DOS 上强制要求启用 fat DS:
- 如果应用走标准的
SDL_RunApp()入口(SDL_main.h提供),SDL 会自动调用__djgpp_nearptr_enable(); - 如果应用定义了
SDL_MAIN_HANDLED自行接管main(),则必须自己在初始化 SDL 之前调用__djgpp_nearptr_enable(),否则视频初始化会失败并给出明确的错误信息。
这一点在源码中有直接佐证——src/video/dos/SDL_dosvideo.c 在VideoInit阶段检查:
if (__djgpp_conventional_base == 0) { return SDL_SetError("DOSVESA: __djgpp_nearptr_enable() was not called. " "Did you define SDL_MAIN_HANDLED without enabling the fat DS trick?"); }__djgpp_conventional_base非零即表示 fat DS 已激活。因此,自定义入口的应用请务必按如下方式组织代码:
#define SDL_MAIN_HANDLED #include <SDL3/SDL.h> #include <sys/nearptr.h> // DJGPP 的 nearptr 支持 int main(int argc, char *argv[]) { if (!__djgpp_nearptr_enable()) { return -1; // 无法启用 fat DS,后续视频初始化必然失败 } if (!SDL_Init(SDL_INIT_VIDEO)) { return -1; } // ... 正常 SDL 代码 ... SDL_Quit(); return 0; }协作式线程调度:setjmp/longjmp 迷你调度器
DOS 没有操作系统级线程。SDL3 的 DOS 移植在 src/core/dos/SDL_dos_scheduler.c 中实现了一个协作式调度器:
- 基于
setjmp/longjmp保存和恢复 CPU 上下文; - 静态线程表(最多 16 个线程,
DOS_MAX_THREADS),默认栈大小 64 KB(见 src/core/dos/SDL_dos_scheduler.h); - 轮转(round-robin)调度:
FindNextRunnable()从当前线程之后线性扫描READY状态线程; - 线程绝不会在指令中途被抢占,切换只发生在显式的让出点。
让出点与主循环要求
上下文切换只发生在SDL_Delay和事件泵等显式让出点,因此文档强调:
请确保主循环定期调用
SDL_PumpEvents或SDL_Delay。
具体建议:
SDL_Delay(0):让出 CPU 给其他线程但不实际睡眠,适合在紧密循环中调用;SDL_Delay(16):对于加载画面这类场景,较长的延时可以让后台线程获得足够的 CPU 时间;- 音频线程会持续通过
DOS_Yield()让出执行权(见下文音频部分),与主线程协作运转。
实现层面的细节
从源码看,调度器的健壮性设计值得一提:
- 栈内存锁页:
DOS_CreateThread通过_go32_dpmi_lock_data(stack, stack_size)锁定新线程的栈,避免切换栈时发生缺页异常(SDL_dos_scheduler.c 第 146 行); - 上下文引导:新线程的
jmp_buf中__esp/__ebp被手工指向新栈顶(16 字节对齐并留出伪返回地址),__eip指向ThreadTrampoline,由它调用用户线程函数并处理退出(第 159-182 行); - join/block/wake:支持
DOS_JoinThread、DOS_BlockCurrentThread(等待信号量)与DOS_WakeThread,构成完整的线程同步原语(第 257-302 行)。
视频:VGA 13h 与 VESA VBE
支持的显示模式
视频驱动(SDLDOSVESA)支持两类模式:
- VGA 模式 13h(320×200×256):任何 VGA 显卡都支持,作为兜底模式。由于多数 VESA BIOS 不把 13h 列入模式列表,代码中用哨兵值
VGA_MODE_13H_SENTINEL标识并回退到0xA000段(见 src/video/dos/SDL_dosmodes.c); - VESA 扩展模式(VBE 1.2+):支持 640×480 及更高分辨率,同时支持线性帧缓冲(VBE 2.0+ LFB)和分段银行(banked)两种访问方式。
只有软件渲染,没有 GPU 渲染器。所有视频模式实际上都是全屏的——驱动在创建窗口时通过SDL_GetClosestFullscreenDisplayMode选择最接近请求尺寸的可用模式(src/video/dos/SDL_dosvideo.c),device_caps被标记为VIDEO_DEVICE_CAPS_FULLSCREEN_ONLY(第 343 行)。默认桌面模式目标为 640×480,应用若想要其他尺寸应显式调用SDL_SetWindowFullscreenMode。
颜色模式
8 位索引色(INDEX8)模式使用可编程的 VGA DAC 调色板,但只有显式通过SDL_PIXELFORMAT窗口创建属性请求时才会被采用。调色板更新时机经过精心设计:在普通路径下,DAC 编程被推迟到垂直消隐期(vblank)执行,以避免可见的调色板闪烁(见 src/video/dos/SDL_dosframebuffer.c 的注释与ProgramVGADAC实现)。
硬件检测与退出恢复
- EGA/CGA 不支持:驱动初始化时检测 VGA 硬件,不存在则失败并给出明确错误信息;
- 干净退出:
VideoQuit通过 VBE 中断0x4F04(保存/恢复 VBE 状态)在退出时完整恢复显卡状态;若原模式不是 VBE 模式,则回退到标准文本模式0x03(BIOS 中断0x10,功能0x0003),确保退出后回到正常的 DOS 命令行界面(src/video/dos/SDL_dosvideo.c)。
硬件页翻转(Page Flipping)
当 LFB 可用且显存足够时,驱动使用硬件页翻转实现无撕裂渲染:先绘制到后台页,再通过 VBE 功能0x4F07(子功能0x0080,设置显示起始行)一次性切换显示页。可选 vsync(通过SDL_SetWindowFramebufferVSync设置)在翻转前等待 vblank(见WaitForVBlank,轮询 VGA 状态端口)。
直接帧缓冲 Hint:为 Quake 这类自绘渲染器而生
SDL_HINT_DOS_ALLOW_DIRECT_FRAMEBUFFER("SDL_DOS_ALLOW_DIRECT_FRAMEBUFFER",自 SDL 3.6.0 起可用,见 include/SDL3/SDL_hints.h)开启一条绕过常规 surface 拷贝的快速路径:
SDL_SetHint(SDL_HINT_DOS_ALLOW_DIRECT_FRAMEBUFFER, "1"); SDL_Window *win = SDL_CreateWindow("quake-like", 320, 200, 0); SDL_Surface *surf = SDL_GetWindowSurface(win); // hint 必须在这之前设置设置后,SDL_UpdateWindowSurface()直接把系统内存中的 surface 拷入 VRAM:
- 首选通过
dosmemput经 VGA 银行窗口(0xA0000)写入——真实硬件测试表明它比 nearptr 写 DPMI 映射的 LFB 明显更快,即使高分辨率下需要切换银行(见 src/video/dos/SDL_dosframebuffer.c 的注释); - 仅当没有可用银行窗口时才回退到 nearptr LFB 路径;
- 只在调色板变化时编程 VGA DAC;
- 页翻转不做 vblank 等待;
- 不进行软件光标合成。
该模式是为 Quake 这类自己管理渲染、追求极限帧吞吐的应用设计的,代价是:
- 无 vsync,撕裂是预期行为;
- 无软件光标,应用需自行绘制光标;
- 在真实硬件上读回 surface 可能很慢(VRAM 未缓存)。
关键约束:hint 必须在第一次调用SDL_GetWindowSurface()之前设置,之后修改无效。从实现看,该 hint 在DOSVESA_CreateWindowFramebuffer中被读取(SDL_dosframebuffer.c 第 274 行),一旦 framebuffer 已按普通路径创建,切换不会生效。
音频:Sound Blaster 家族与 IRQ 驱动环形缓冲
支持的硬件与格式
音频驱动(SDL_AUDIO_DRIVER_DOS_SOUNDBLASTER)支持三代 Sound Blaster:
| 硬件 | DSP 版本 | 格式 |
|---|---|---|
| SB16 | DSP ≥ 4.x | 16 位立体声有符号(S16LE),5–44.1 kHz |
| SB Pro | DSP 3.x | 8 位立体声无符号(U8),最高 22050 Hz(立体声) |
| SB 2.0 / 1.x | DSP 2.x / 1.x | 8 位单声道无符号(U8) |
硬件配置完全自动:从BLASTER环境变量解析。解析器(IsSoundBlasterPresent)识别A(基端口,十六进制)、I(IRQ)、D(8 位 DMA 通道)、H(16 位高 DMA 通道)等关键字,忽略M(混音芯片端口)、P(MPU-401)、T(设备类型)等无关项(src/audio/dos/SDL_dosaudio_sb.c)。缺失或错误的变量会返回明确错误。典型配置形如:
SET BLASTER=A220 I5 D1 H5 T6检测流程还会通过 DSP 复位握手和版本查询(命令0xE1)确认端口上确实是 Sound Blaster,而非其他设备。
环形缓冲架构:约 45 ms 的余量
音频路径采用IRQ 驱动的环形缓冲,这是移植文档强调的核心设计:
- SDL 音频线程(协作式)通过完整的 SDL 音频管线(含分配、互斥)将混音结果写入环形缓冲;
- Sound Blaster IRQ 处理器在中断上下文中只做一件事:从环形缓冲拷贝一个 chunk 到 DMA 半缓冲(纯
memcpy+ 端口 I/O,不碰 DPMI、不分配、不加锁、不用 FPU); - 环形缓冲容量为4 个 chunk(
RING_BUFFER_CHUNKS 4),在 44100 Hz 下约等于45 ms的缓冲余量——足以覆盖 22 fps 的帧时间。
中断处理器和环形缓冲涉及的代码与数据全部经过_go32_dpmi_lock_data/_go32_dpmi_lock_code锁页,确保中断发生时不会因缺页或 DPMI 重入而崩溃(SDL_dosaudio_sb.c 第 339-356 行)。
对游戏主循环的建议
- 游戏帧率 ≥ 22 fps 时,音频无需额外处理即可保持流畅;
- 帧率低于 20 fps 时,在游戏循环中段加入一次
SDL_Delay(0)让音频线程有机会补充环形缓冲即可; - 音频线程的
WaitDevice在缓冲满时也会DOS_Yield(),让主线程继续运行——协作式调度的典型体现(第 168-187 行)。
其他约束
- DMA 缓冲必须分配在常规内存中且不能跨越 64 KB 物理页边界(8237 DMA 控制器的硬件限制)。
DOS_AllocateDMAMemory采用"分配 2 倍大小、必要时使用后半段"的标准手法解决(见 src/core/dos/SDL_dos.c); - 缓冲大小上限为 32 KB(
buffer_size > 32 * 1024报错),因为 DMA 缓冲要翻倍后塞进 64 KB 段内(SDL_dosaudio_sb.c 第 226-228 行); - 音频录制未实现,驱动只提供播放(
OnlyHasDefaultPlaybackDevice = true)。
输入:键盘、鼠标与游戏杆
- 键盘:由 IRQ1 驱动,支持完整扩展扫描码(0xE0 前缀,即方向键、小键盘区等);
- 鼠标:通过 INT 33h 鼠标驱动,使用 mickeys(相对位移脉冲)提供相对移动;
- 游戏杆:通过直接读游戏端口 0x201(gameport)实现,带软件校准。必须在初始化时已连接,没有热插拔检测;文档特别提示:运行中断开游戏杆可能导致性能下降。
已知限制
- 无动态加载:不支持共享库/动态加载(没有
SDL_LoadObject)。文档提到 DXE(DJGPP 的动态可执行文件格式)支持"未来可能加入"。因此,依赖插件机制的 DOS 应用需要以静态链接方式编译进所有功能。
从源码进一步探索
以下文件可以帮助你深入理解 DOS 移植的每个细节:
- 构建与工具链:build-scripts/i586-pc-msdosdjgpp.cmake
- 协作式调度器:src/core/dos/SDL_dos_scheduler.c、src/core/dos/SDL_dos_scheduler.h
- 内存/中断辅助:src/core/dos/SDL_dos.c、src/core/dos/SDL_dos.h
- 视频驱动:src/video/dos/SDL_dosvideo.c、src/video/dos/SDL_dosmodes.c、src/video/dos/SDL_dosframebuffer.c
- 音频驱动:src/audio/dos/SDL_dosaudio_sb.c、src/audio/dos/SDL_dosaudio_sb.h
- Hint 定义:include/SDL3/SDL_hints.h(
SDL_HINT_DOS_ALLOW_DIRECT_FRAMEBUFFER,第 793-808 行)
小结
SDL3 的 DOS 移植是一套精心适配 16 位时代硬件约束的完整实现:用 fat DS 打通保护模式与硬件地址空间,用 setjmp/longjmp 构建协作式线程,用 IRQ + 环形缓冲让 Sound Blaster 在低帧率下依然流畅,用dosmemput/LFB 双路径适配不同档次的 VESA 显卡。对开发者而言,最重要的三条实践准则是:自定义main()时手动启用 fat DS、主循环定期调用SDL_PumpEvents/SDL_Delay让出 CPU、需要极致帧吞吐时在首次SDL_GetWindowSurface()之前设置直接帧缓冲 hint。
【免费下载链接】SDLSimple DirectMedia Layer项目地址: https://gitcode.com/GitHub_Trending/sd/SDL
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考