使用 GNU C 内联汇编在 VGA 内存中绘制一个字符

Drawing a character in VGA memory with GNU C inline assembly

我正在学习使用 C 语言和内联汇编在 DOS 中进行一些低级 VGA 编程。现在我正在尝试创建一个在屏幕上打印出字符的函数。

这是我的代码:

//This is the characters BITMAPS
uint8_t characters[464] = {
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x20,0x20,0x20,0x20,0x00,0x20,0x00,0x50,
  0x50,0x00,0x00,0x00,0x00,0x00,0x50,0xf8,0x50,0x50,0xf8,0x50,0x00,0x20,0xf8,0xa0,
  0xf8,0x28,0xf8,0x00,0xc8,0xd0,0x20,0x20,0x58,0x98,0x00,0x40,0xa0,0x40,0xa8,0x90,
  0x68,0x00,0x20,0x40,0x00,0x00,0x00,0x00,0x00,0x20,0x40,0x40,0x40,0x40,0x20,0x00,
  0x20,0x10,0x10,0x10,0x10,0x20,0x00,0x50,0x20,0xf8,0x20,0x50,0x00,0x00,0x20,0x20,
  0xf8,0x20,0x20,0x00,0x00,0x00,0x00,0x00,0x60,0x20,0x40,0x00,0x00,0x00,0xf8,0x00,
  0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x60,0x60,0x00,0x00,0x08,0x10,0x20,0x40,0x80,
  0x00,0x70,0x88,0x98,0xa8,0xc8,0x70,0x00,0x20,0x60,0x20,0x20,0x20,0x70,0x00,0x70,
  0x88,0x08,0x70,0x80,0xf8,0x00,0xf8,0x10,0x30,0x08,0x88,0x70,0x00,0x20,0x40,0x90,
  0x90,0xf8,0x10,0x00,0xf8,0x80,0xf0,0x08,0x88,0x70,0x00,0x70,0x80,0xf0,0x88,0x88,
  0x70,0x00,0xf8,0x08,0x10,0x20,0x20,0x20,0x00,0x70,0x88,0x70,0x88,0x88,0x70,0x00,
  0x70,0x88,0x88,0x78,0x08,0x70,0x00,0x30,0x30,0x00,0x00,0x30,0x30,0x00,0x30,0x30,
  0x00,0x30,0x10,0x20,0x00,0x00,0x10,0x20,0x40,0x20,0x10,0x00,0x00,0xf8,0x00,0xf8,
  0x00,0x00,0x00,0x00,0x20,0x10,0x08,0x10,0x20,0x00,0x70,0x88,0x10,0x20,0x00,0x20,
  0x00,0x70,0x90,0xa8,0xb8,0x80,0x70,0x00,0x70,0x88,0x88,0xf8,0x88,0x88,0x00,0xf0,
  0x88,0xf0,0x88,0x88,0xf0,0x00,0x70,0x88,0x80,0x80,0x88,0x70,0x00,0xe0,0x90,0x88,
  0x88,0x90,0xe0,0x00,0xf8,0x80,0xf0,0x80,0x80,0xf8,0x00,0xf8,0x80,0xf0,0x80,0x80,
  0x80,0x00,0x70,0x88,0x80,0x98,0x88,0x70,0x00,0x88,0x88,0xf8,0x88,0x88,0x88,0x00,
  0x70,0x20,0x20,0x20,0x20,0x70,0x00,0x10,0x10,0x10,0x10,0x90,0x60,0x00,0x90,0xa0,
  0xc0,0xa0,0x90,0x88,0x00,0x80,0x80,0x80,0x80,0x80,0xf8,0x00,0x88,0xd8,0xa8,0x88,
  0x88,0x88,0x00,0x88,0xc8,0xa8,0x98,0x88,0x88,0x00,0x70,0x88,0x88,0x88,0x88,0x70,
  0x00,0xf0,0x88,0x88,0xf0,0x80,0x80,0x00,0x70,0x88,0x88,0xa8,0x98,0x70,0x00,0xf0,
  0x88,0x88,0xf0,0x90,0x88,0x00,0x70,0x80,0x70,0x08,0x88,0x70,0x00,0xf8,0x20,0x20,
  0x20,0x20,0x20,0x00,0x88,0x88,0x88,0x88,0x88,0x70,0x00,0x88,0x88,0x88,0x88,0x50,
  0x20,0x00,0x88,0x88,0x88,0xa8,0xa8,0x50,0x00,0x88,0x50,0x20,0x20,0x50,0x88,0x00,
  0x88,0x50,0x20,0x20,0x20,0x20,0x00,0xf8,0x10,0x20,0x40,0x80,0xf8,0x00,0x60,0x40,
  0x40,0x40,0x40,0x60,0x00,0x00,0x80,0x40,0x20,0x10,0x08,0x00,0x30,0x10,0x10,0x10,
  0x10,0x30,0x00,0x20,0x50,0x88,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0x00,0xf8,
  0x00,0xf8,0xf8,0xf8,0xf8,0xf8,0xf8};
/**************************************************************************
 *  put_char                                                              *
 *     Print char                                                         *
 **************************************************************************/
void put_char(int x ,int y,int ascii_char ,byte color){

    __asm__(
        "push %si\n\t"
        "push %di\n\t"
        "push %cx\n\t"
        "mov color,%dl\n\t"   //test color
        "mov ascii_char,%al\n\t"  //test char
        "sub ,%al\n\t"
        "mov ,%ah\n\t"
        "mul %ah\n\t"
        "lea $characters,%si\n\t"
        "add %ax,%si\n\t"
        "mov ,%cl\n\t"
        "0:\n\t"
        "segCS %lodsb\n\t"   
        "mov ,%ch\n\t"
        "1:\n\t"    
        "shl ,%al\n\t"
        "jnc 2f\n\t"
        "mov %dl,%ES:(%di)\n\t"
        "2:\n\t"
        "inc %di\n\t"
        "dec %ch\n\t"
        "jnz 1b\n\t"
        "add 0-6,%di\n\t"
        "dec %cl\n\t"
        "jnz  0b\n\t"
        "pop %cx\n\t"
        "pop %di\n\t"
        "pop %si\n\t"
        "retn"

    );


}

我从这一系列用 PASCAL 编写的教程中指导自己:http://www.joco.homeserver.hu/vgalessons/lesson8.html

我根据 gcc 编译器更改了汇编语法,但仍然出现以下错误:

Operand mismatch type for 'lea'
No such instruction 'segcs lodsb'
No such instruction 'retn'

编辑:

我一直在努力改进我的代码,至少现在我在屏幕上看到了一些东西。这是我更新的代码:

/**************************************************************************
 *  put_char                                                              *
 *     Print char                                                         *
 **************************************************************************/
void put_char(int x,int y){
    int char_offset;
    int l,i,j,h,offset;
    j,h,l,i=0;
    offset = (y<<8) + (y<<6) + x;               
    __asm__(

        "movl _VGA, %%ebx;" // VGA memory pointer   
        "addl %%ebx,%%edi;"  //%di points to screen


        "mov _ascii_char,%%al;"
        "sub ,%%al;"
        "mov ,%%ah;"
        "mul %%ah;"

        "lea _characters,%%si;"
        "add %%ax,%%si;"   //SI point to bitmap

        "mov ,%%cl;"

        "0:;"
            "lodsb %%cs:(%%si);"   //load next byte of bitmap 

            "mov ,%%ch;"
        "1:;"   
            "shl ,%%al;"
            "jnc 2f;"
            "movb %%dl,(%%edi);"  //plot the pixel
        "2:\n\t"
            "incl %%edi;"
            "dec %%ch;"
            "jnz 1b;"
            "addl 0-6,%%edi;"
            "dec %%cl;"
            "jnz  0b;"


        :  "=D" (offset)
        : "d" (current_color)

    );


}

如果您看到上图,我正在尝试写信 "S"。结果是您在屏幕左上角看到的绿色像素。无论我给函数什么 x 和 y,它总是在同一点上绘制像素。

谁能帮我更正我的代码?

请参阅下文,了解对您的 put_char 函数的一些具体错误的分析,以及可能有效的版本。 (我不确定 %cs 段覆盖,但除此之外它应该按照您的意图进行)。


学习 DOS 和 16 位 asm 并不是学习 asm 的最佳方式

首先,DOS 和 16 位 x86 已经完全过时了,并且 比普通的 64 位 x86 更容易学习。即使 32 位 x86 已经过时,但仍在 Windows 世界中广泛使用。

32 位和 64 位代码不必关心很多 16 位限制/复杂性,例如段或寻址模式中有限的寄存器选择。一些现代系统确实使用段覆盖 thread-local 存储,但学习如何在 16 位代码中使用段几乎与此无关。

了解 asm 的主要好处之一是调试/分析/优化实际程序。如果你想了解如何编写 C 或其他 high-level 代码 , you'll probably be . This will be 64-bit (or 32-bit). (e.g. see Matt Godbolt's CppCon2017 talk: “What Has My Compiler Done for Me Lately? Unbolting the Compiler's Lid”,它对 total 初学者阅读 x86 asm 有很好的介绍,并查看编译器输出)。

Asm 知识在查看 performance-counter 注释二进制反汇编结果时很有用(perf stat ./a.out && perf report -Mintel:参见 Chandler Carruth's CppCon2015 talk: "Tuning C++: Benchmarks, and CPUs, and Compilers! Oh My!")。积极的编译器优化意味着查看每个源代码行的周期/cache-miss/停顿计数比每条指令提供的信息少得多。

此外,要让您的程序真正任何事情,它必须直接与硬件对话,或者进行系统调用。学习文件访问和用户输入的 DOS 系统调用完全是浪费时间(除了回答源源不断的关于如何在 16 位代码中读取和打印 multi-digit 数字的 SO 问题)。它们与当前主要操作系统中的 API 完全不同。开发新的 DOS 应用程序是没有用的,所以当你到了用你的 asm 知识做某事的阶段时,你必须学习另一个 API(以及 ABI)。

在 8086 模拟器上学习 asm 更受限制:186、286 和 386 添加了许多方便的指令,如 imul ecx, 15,使 ax 不那么“特殊”。将自己限制在仅适用于 8086 的指令意味着您会找出“糟糕”的做事方式。其他大的是 movzx / movsx,按立即数移位(1 除外)和 push immediate。除了性能之外,当这些可用时编写代码也更容易,因为您不必编写循环来移位超过 1 位。


有关自学 asm 的更好方法的建议

我学习 asm 的主要方式是阅读编译器输出,然后进行小的更改。当我不真正理解事物时,我没有尝试用 asm 编写东西,但是如果你要快速学习(而不是仅仅在调试/分析 C 时加深理解),你可能需要测试你的理解编写自己的代码。您确实需要了解基础知识,即有 8 或 16 个整数寄存器 + 标志和指令指针,并且每条指令都会对机器的当前架构状态进行 well-defined 修改。 (有关每条指令的完整描述,请参阅英特尔 insn 参考手册( wiki 中的 links,以及 更多好东西 )。

您可能想从简单的事情开始,例如在 asm 中编写单个函数,作为更大程序的一部分。了解进行系统调用所需的 asm 类型很有用,但在实际程序中,通常只对不涉及任何系统调用的内部循环的 hand-write asm 有用。编写 asm 来读取输入和打印结果是 time-consuming,所以我建议在 C 中完成该部分。确保您阅读了编译器输出并了解发生了什么,以及整数和字符串之间的区别,以及 strtolprintf 的作用,即使它们不是您自己编写的。

一旦您认为自己对基础知识有了足够的了解,就可以在一些您熟悉的程序中找到一个函数 and/or 感兴趣,看看您是否可以击败编译器并保存指令(或使用更快的指令).或者自己实现它 而不是 使用编译器输出作为起点,以你觉得更有趣的为准。 可能很有趣,尽管那里的重点是找到让编译器生成最佳 ASM 的 C 源代码。

如何尝试解决自己的问题(在提出 SO 问题之前)

人们问“我如何在 asm 中做 X”有很多 SO 问题,答案通常是“与在 C 中做的一样”。不要因为不熟悉 asm 而忘记了如何编程。弄清楚函数操作的数据需要发生什么,然后弄清楚如何在 asm 中做到这一点。如果您遇到困难并且不得不提出问题,您应该拥有大部分有效的实施,只有一部分帽子你不知道一步使用什么指令。

您应该使用 32 位或 64 位 x86 执行此操作。我建议使用 64 位,因为 ABI 更好,但是 32 位函数将迫使您更多地使用堆栈。因此,这可能会帮助您理解 call 指令如何将 return 地址放入堆栈,以及调用者实际推送的 args 所在的位置。 (这似乎是您试图通过使用内联 asm 来避免处理的问题)。


直接对硬件进行编程很巧妙,但不是一般有用的技能

通过直接修改视频 RAM 来学习如何做图形没有用,除了满足对计算机过去如何工作的好奇心。你不能将这些知识用于任何事情。现代图形 API 的存在是为了让多个程序在他们自己的屏幕区域中绘制,并允许间接(例如,在纹理上绘制而不是直接在屏幕上绘制,所以 3D window-flipping alt-tab可以看起来很花哨)。不直接在视频 RAM 上绘图的原因太多,无法在此处列出。

可以在像素图缓冲区上绘图,然后使用图形 API 将其复制到屏幕上。尽管如此,做位图图形或多或少已经过时了,除非你正在为 PNG 或 JPEG 或其他东西生成图像(例如,在 Web 服务的 back-end 代码中优化将直方图箱转换为散点图)。现代图形 API 抽象了分辨率,因此无论每个像素有多大,您的应用程序都可以以合理的尺寸绘制内容。 (小但分辨率极高的屏幕与低分辨率下的大电视)。

写入内存并看到一些变化是很酷的 on-screen。或者更好的是,将 LED(带有小电阻)连接到并行端口上的数据位,然后 运行 一个 outb 指令将它们 on/off。我很久以前就在我的 Linux 系统上这样做过。我做了一个小包装程序,它使用 iopl(2) 和内联 asm,运行 它作为根。您可能可以在 Windows 上做类似的事情。您不需要 DOS 或 16 位代码来与硬件对话。

in/out 指令,以及正常的 loads/stores 到 memory-mapped IO 和 DMA,是真正的驱动程序与硬件通信的方式,包括比并行端口。了解您的硬件“真正”如何工作很有趣,但只有在您真正感兴趣或想编写驱动程序时才花时间在上面。 Linux 源代码树包括用于大量硬件的驱动程序,并且通常有很好的注释,所以如果你喜欢阅读代码就像编写代码一样,这是另一种方式来感受读取驱动程序在与硬件对话时所做的事情.

了解引擎盖下的工作原理通常是件好事。如果您想要 了解很久以前图形是如何工作的(使用 VGA 文本模式和颜色/属性字节),那么当然,请发疯。请注意,现代操作系统不使用 VGA 文本模式,因此您甚至没有了解现代计算机的幕后情况。

许多人喜欢 https://retrocomputing.stackexchange.com/,重温计算机不那么复杂且无法支持那么多抽象层的简单时代。请注意,这就是您正在做的事情。如果您确定就是您想要了解 asm / 硬件的原因,我可能是学习为现代硬件编写驱动程序的良好垫脚石。


内联汇编

您使用内联 ASM 的方法完全不正确。你似乎想用 asm 编写整个函数,所以你应该只做 that。例如把你的代码放在 asmfuncs.S 之类的地方。如果您想继续使用 GNU / AT&T 语法,请使用 .S;或使用 .asm 如果你想使用 Intel / NASM / YASM 语法(我会推荐,因为官方手册都使用 Intel 语法。请参阅 wiki 获取指南和手册。 )

GNU 内联汇编是 最难 的学习 ASM 的方法。您必须了解您的 asm 所做的一切,以及编译器需要了解的内容。真的很难把一切都做好。例如,在您的编辑中,该内联 asm 块修改了许多您未列为破坏的寄存器,包括 %ebx 这是一个 call-preserved 寄存器(因此即使该函数不是' t内联)。至少你去掉了 ret,这样当编译器将这个函数内联到调用它的循环中时,事情就不会那么糟糕了。如果这听起来真的很复杂,那是因为它确实很复杂,这也是为什么 你不应该使用内联 asm 来学习 asm.

的部分原因

有更多关于内联 asm 以及如何使用它的 link。


让这个烂摊子工作,也许

这部分可以单独回答,但我会放在一起。

除了你的整个方法从根本上说是一个坏主意之外,你的 put_char 函数至少有一个 特定问题 :你使用 offset 作为output-only 运行运行d。 gcc 很高兴 compi将您的整个函数转换为单个 ret 指令,因为 asm 语句不是 volatile,并且未使用其输出。 (假定没有输出的内联 asm 语句为 volatile。)

I put your function on godbolt,所以我可以查看编译器围绕它生成的程序集。 link 是固定的 maybe-working 版本,具有 correctly-declared 破坏、评论、清理和优化。如果外部 link 曾经中断,请参阅下面的相同代码。

我使用带有 -m16 选项的 gcc 5.3,这与使用真正的 16 位编译器不同。它仍然以 32 位方式执行所有操作(在堆栈上使用 32 位地址、32 位 ints 和 32 位函数参数),但告诉汇编器 CPU 将处于 16 位模式,因此它会知道何时发出 operand-size 和 address-size 前缀。

即使您 compile your original version with -O0,编译器也会计算 offset = (y<<8) + (y<<6) + x;,但不会将其放入 %edi,因为您没有要求它这样做。将其指定为另一个输入 ope运行d 会起作用。在内联 asm 之后,它将 %edi 存储到 -12(%ebp) 中,其中 offset 存在。


put_char其他错误:

你通过全局变量而不是函数参数将 2 个东西(ascii_charcurrent_color)传递给你的函数。呸,真恶心。 VGAcharacters 是常量,所以从全局加载它们看起来并不那么糟糕。用 asm 编写意味着只有当良好的编码实践对性能有一定帮助时,您才应该忽略它。由于调用者可能必须将这些值存储到全局变量中,因此与调用者将它们作为函数参数存储在堆栈中相比,您没有保存任何东西。对于 x86-64,你会失去性能,因为调用者可以将它们传递到寄存器中。

还有:

j,h,l,i=0;  // sets i=0, does nothing to j, h, or l.
       // gcc warns: left-hand operand of comma expression has no effect
j;h;l;i=0;  // equivalent to this

j=h=l=i=0;  // This is probably what you meant

offset外,所有局部变量都未使用。你打算用 C 或其他语言编写它吗?

您对 characters 使用 16 位地址,但对 VGA 内存使用 32 位寻址模式。我认为这是故意的,但我不知道它是否正确。另外,您确定应该对来自 characters 的负载使用 CS: 覆盖吗? .rodata 部分是否进入代码段?尽管您没有将 uint8_t characters[464] 声明为 const,所以它可能只是在 .data 部分中。我认为自己很幸运,因为我还没有真正为分段内存模型编写代码,但这看起来仍然很可疑。

如果您真的在使用 djgpp,那么根据 Michael Petch 的评论,您的代码将在 32 位模式下运行。因此使用 16 位地址不是一个好主意。


优化

您可以通过这样做完全避免使用 %ebx,而不是加载到 ebx 然后将 %ebx 添加到 %edi

 "add    _VGA, %%edi\n\t"   // load from _VGA, add to edi.

您不需要 lea 将地址存入寄存器。你可以只使用

    "mov    %%ax, %%si\n\t"
    "add    $_characters, %%si\n\t"

$_characters表示地址为立即数。通过将此与之前计算的偏移量结合到位图的 characters 数组中,我们可以节省大量指令。 imul 的 immediate-operand 形式让我们首先在 %si 中产生结果:

    "movzbw _ascii_char,%%si\n\t"
       //"sub    ,%%ax\n\t"      // AX = ascii_char - 32
    "imul   , %%si, %%si\n\t"
    "add    $(_characters - 32*7), %%si\n\t"  // Do the -32 at the same time as adding the table address, after multiplying
    // SI points to characters[(ascii_char-32)*7]
    // i.e. the start of the bitmap for the current ascii character.

由于这种形式的imul只保留16*16 -> 32b乘法的低16b,the 2 and 3 operand forms imul can be used for signed or unsigned multiplies, which is why only imul (not mul) has those extra forms. For larger operand-size multiplies, 2 and 3 operand imul is faster,因为它不必将高半存储在[=76中=].

您可以稍微简化内循环,但会使外循环稍微复杂化:您可以在 shl , %al 设置的零标志上 b运行ch,而不是使用 a柜台。这将使它也变得不可预测,例如 non-foreground 像素的跳过存储,因此增加的 b运行ch 错误预测可能比额外的 do-nothing 循环更糟糕。这也意味着您每次都需要在外循环中重新计算 %edi,因为内循环不会 运行 固定次数。但它可能看起来像:

    ... same first part of the loop as before
    // re-initialize %edi to first_pixel-1, based on outer-loop counter
    "lea  -1(%%edi), %%ebx\n"
    ".Lbit_loop:\n\t"      // map the 1bpp bitmap to 8bpp VGA memory
        "incl   %%ebx\n\t"       // inc before shift, to preserve flags
        "shl    ,%%al\n\t"
        "jnc    .Lskip_store\n\t"   // transparency: only store on foreground pixels
        "movb   %%dl,(%%ebx)\n"  //plot the pixel
    ".Lskip_store:\n\t"
        "jnz  .Lbit_loop\n\t"    // flags still set from shl

        "addl   0,%%edi\n\t"  // WITHOUT the -6
        "dec    %%cl\n\t"
        "jnz  .Lbyte_loop\n\t"

请注意,您的字符位图中的位将映射到 VGA 内存中的字节,如 {7 6 5 4 3 2 1 0},因为您正在测试 left 移出的位] 转移。所以它从 MSB 开始。寄存器中的位总是“大端”。左移乘以 2,即使在像 x86 这样的 little-endian 机器上也是如此。 Little-endian 仅影响内存中 字节 的排序,而不影响字节中的位,甚至寄存器中的字节。


您的函数的一个版本可能会达到您的预期。

这个和神马一样link.

void put_char(int x,int y){
    int offset = (y<<8) + (y<<6) + x;
    __asm__ volatile (  // volatile is implicit for asm statements with no outputs, but better safe than sorry.

        "add    _VGA, %%edi\n\t" // edi points to VGA + offset.

        "movzbw _ascii_char,%%si\n\t"   // Better: use an input operand

        //"sub    ,%%ax\n\t"      // AX = ascii_char - 32
        "imul   , %%si, %%si\n\t"     // can't fold the load into this because it's not zero-padded
        "add    $(_characters - 32*7), %%si\n\t"  // Do the -32 at the same time as adding the table address, after multiplying
        // SI points to characters[(ascii_char-32)*7]
        // i.e. the start of the bitmap for the current ascii character.

        "mov    ,%%cl\n"

        ".Lbyte_loop:\n\t"
            "lodsb  %%cs:(%%si)\n\t"   //load next byte of bitmap 

            "mov    ,%%ch\n"
        ".Lbit_loop:\n\t"      // map the 1bpp bitmap to 8bpp VGA memory
            "shl    ,%%al\n\t"
            "jnc    .Lskip_store\n\t"   // transparency: only store on foreground pixels
            "movb   %%dl,(%%edi)\n"  //plot the pixel
        ".Lskip_store:\n\t"
            "incl   %%edi\n\t"
            "dec    %%ch\n\t"
            "jnz  .Lbit_loop\n\t"

            "addl   0-6,%%edi\n\t"
            "dec    %%cl\n\t"
            "jnz  .Lbyte_loop\n\t"


        : "+&D" (offset)        // EDI modified by the asm, compiler needs to know that, so use a read-write "+" input.  Early-clobber "&" because we read the other input after modifying this.
        : "d" (current_color)  // used read-only
        : "%eax", "%ecx", "%esi", "memory"
         // omit the memory clobber if your C never touches VGA memory, and your asm never loads/stores anywhere else.
         // but that's not the case here: the asm loads from memory written by C
         // without listing it as a memory operand (even a pointer in a register isn't sufficient)
         // so gcc might optimize away "dead" stores to it, or reorder the asm with loads/stores to it.    
    );
}

回复:"memory" 破坏,参见

我没有使用虚拟输出 ope运行ds 将寄存器分配留给编译器自行决定,但这是减少为内联 asm 在正确位置获取数据的开销的好主意。 (额外的 mov 说明)。例如,这里没有必要强制编译器将 offset 放在 %edi 中。它可能是我们尚未使用的任何寄存器。