在Linux控制台(NASM汇编)中显示所有ASCII字符

3

我看了一篇有关nasm的教程,其中有一个代码示例,展示了整个ascii字符集。我基本上理解了所有内容,唯独不明白为什么要推送ecx并弹出ecx,因为我看不出它与代码的其余部分有什么关系。Ecx的值为256,因为我们想要所有字符,但不知道它在哪里以及如何使用。当我们推送和弹出ecx时,到底发生了什么?为什么要将achar的地址移动到dx中?我看不出我们用dx做任何事情。我知道我们需要增加achar的地址,但我困惑的是增量如何与ecx和dx相关联。希望能得到一些见解。

   section  .text
       global _start        ;must be declared for using gcc

    _start:                 ;tell linker entry point
       call    display
       mov  eax,1           ;system call number (sys_exit)
       int  0x80            ;call kernel

    display:
       mov    ecx, 256

    next:
       push    ecx
       mov     eax, 4
       mov     ebx, 1
       mov     ecx, achar
       mov     edx, 1
       int     80h

       pop     ecx  
       mov  dx, [achar]
       cmp  byte [achar], 0dh
       inc  byte [achar]
       loop    next
       ret

    section .data
    achar db '0'  

2
ecx被用作循环计数器,由于它加载了achar用于系统调用,所以它的值需要保留。push/pop是一种实现这一目的的方法。至于mov dx, [achar],似乎确实是不必要的。 - Jester
1
不仅如此,ASCII 只有 128 个字符,而不是 256 个,更不要让我开始谈缩进了..... - Ray Toal
这里写着256和特殊字符。 - Asperger
@RayToal 他们为什么要比较字节?对我来说似乎有些多余。 - Asperger
3
没听过“扩展ASCII”,可能是有人捏造的词,或者是某个特定厂商的东西。不管怎样,我同意你的观点,cmp是无意义的。自己编写这个程序可能是一个很好的练习;我认为作者正在尝试一种不同的方法,并在其中留下了一些垃圾代码。 - Ray Toal
显示剩余12条评论
1个回答

3

我基本上都能理解

那么你比我要高明一些...(尽管从你的进一步评论中,你意识到代码中还有其他无意义的东西 :) )。

为什么我们要推送 ecx 并弹出 ecx,因为我看不出它与其余代码有何关系。Ecx 的值为 256,因为我们想要所有字符,但不知道它在哪里和如何使用。

它被 LOOP 指令使用(这不是一个好主意:为什么循环指令很慢?),它将递减 ecx,并在值大于零时跳转,即它是一个倒计时循环机制。

作为int 0x80服务调用需要ecx作为内存地址值,计数器通过push/pop保存/恢复。更高效的方法是将计数器值放入某个备用寄存器,例如esi,并执行dec esi jnz next。更高效的方法是重新使用字符值本身,如果输出以零值而不是零数字开头,则可以使用inc byte [achar]后的零标志来检测循环条件。
achar db '0'

我不太明白为什么“显示所有ASCII字符”的起始数字是零(值为48),这对我来说很奇怪,我会从零开始。但这又有另一个问题,在任何常见的Linux安装中,它都是UTF8编码,因此有效的可打印单字节字符只有32-126的值(与普通7位ASCII编码相同,使示例的这部分工作正常),而0-31和127的值是不可打印的控制字符,也与常见的7b ASCII编码相同。值128-255表示UTF8编码的多字节字符(例如:ř是两个字节0xC5 0x99),而作为单个字节时,它们是无效的字节序列,因为缺少UTF8“代码点”字节的其余部分。
在DOS时代,你可以直接编写代码写入VGA文本模式视频内存中的8位值,取值范围从零到255,每个值都有不同的图形表示方式,你可以指定VGA自定义字体或已知的代码页来表示特定字符,这有时也被称为“扩展ASCII”,但常见的DOS安装与你评论中的链接有所不同,包含了更多的绘制框字符。这包括\r\n控制字符,对于VGA来说,它们只是另一个字体字形,而不是换行和新行控制字符(这个意义是由BIOS/DOS服务调用创建的,该调用将内部光标移动到下一行并且丢弃输出的字符,而不是输出\n字符)。
在Linux控制台I/O中无法重新创建这个过程(除非UTF8字体包含所有奇怪的DOS字形,并且你将输出它们的正确UTF8编码而不是单字节值)。
结论是,该示例以值'0'48)开始,直到值126输出正确的可打印ASCII字符,在126之后输出"something",由于这些字节有时会形成无效的UTF8编码,因此我技术上称其为具有未定义行为的“伪造”输出,您可能会在不同的Linux版本和控制台设置下获得不同的结果。
另外,NASM风格的注意事项:在标签后面加上冒号,即achar: db '0',这将在您意外使用指令助记符作为标签时保护您,例如loop:dec: db 'd'
   mov  dx, [achar]

dx 没有被进一步使用,因此这是无用的指令。

   cmp  byte [achar], 0dh

这个比较的标志位也没有被进一步使用,因此这也是无用的。
所以调整后的示例可以看起来像这样:
section  .text
    global _start       ;must be declared for using gcc

_start:                 ;tell linker entry point
    call    display
    mov     eax,1       ;system call number (sys_exit)
    int     0x80        ;call kernel

; displays all valid printable ASCII characters (32-126), and new-line after.
display:
    mov     byte [achar], ' '   ; first valid printable ASCII
next:
    mov     eax, 4
    mov     ebx, 1
    mov     ecx, achar
    mov     edx, 1
    int     0x80
    inc     byte [achar]
    cmp     byte [achar], 126
    jbe     next        ; repeat until all chars are printed
    ; that will output all 32..126 printable ASCII characters

    ; display one more character, new line (reuse of registers)
    mov     byte [achar], `\n`  ; NASM uses backticks for C-like meta chars
    mov     eax, 4      ; ebx, ecx and edx are already set from loop above
    int     0x80
    ret

section .bss
achar: resb 1           ; reserve one byte for character output

但更合理的做法是先将整个输出准备好,然后一次性输出,就像这样:

section  .text
    global _start       ;makes symbol "_start" global (visible for linker)

_start:                 ;linker's default entry point
    call    display
    mov     eax,1       ;system call number (sys_exit)
    int     0x80        ;call kernel

; displays all valid printable ASCII characters (32-126), and new-line after.
display:
    ; prepare in memory string with all ASCII chars and new-line
    mov     al,' '      ; first valid printable ASCII
    mov     edi, allAsciiChars
    mov     ecx, edi    ; this address will be used also for "write" int 0x80
nextChar:
    mov     [edi], al
    inc     edi
    inc     al
    cmp     al, 126
    jbe     nextChar
    ; add one more new line at end
    mov     byte [edi], `\n`
    ; display the prepared "string" in one "write" call
    mov     eax, 4      ; sys_write, ecx is already set
    mov     ebx, 1      ; file descriptor STDOUT
    lea     edx, [edi+1]; edx = edi+1 (memory address beyond last char)
    sub     edx, ecx    ; edx = length of generated string
    int     0x80
    ret

section .bss
allAsciiChars: resb 126-' '+1+1 ; reserve space for ASCII characters and \n

所有示例都是在64位Linux上使用nasm 2.11.08尝试的(基于Ubuntu 16.04的“KDE neon”发行版),并通过以下命令构建:

nasm -f elf32 -F dwarf -g test.asm -l test.lst -w+all
ld -m elf_i386 -o test test.o

带有输出:
$ ./test
 !"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdefghijklmnopqrstuvwxyz{|}~

我能问一下,为什么你选择的一些寄存器是16位的,而另一些寄存器是32位的版本?这是某种内存或性能优化吗? - Asperger
@Asperger 我在任何地方都没有使用16位寄存器。alrax (eax, ax, ah:al) 的最低的8位部分。我使用 al 来处理单字节元素 (8位),因为7位ASCII就是这样使用的,最高的第8位设置为零 (即值为 0x00..0x7F,如果您能心算并将其转换成位,您可以很容易地看到顶部的第8个 0x80 位是清除的)。它由数据结构 (ASCII编码的“字符串”) 决定,如果我正在准备16位或32位元素的数组,我会使用16位的 ax 或 32位的 eax,等等。尽管您可以从字节中构建更复杂的任何内容。 - Ped7g
@Asperger mov edi, allAsciiChars 把寄存器 edi 装入一个 32 位值,它代表着在 .bss 部分保留的缓冲区的内存地址。我预留了 96 个字节,而其中第一个字节的地址是 allAsciiChars。在 32 位模式下,内存地址是 32 位整数值,比如 1234。然后用 mov ecx, edi 把这个地址值复制到寄存器 ecx 中。如果我执行 mov ecx, [edi],那么我会加载来自缓冲区的内存内容中的前四个字节,此时它们都是零,因为 Linux 操作系统在启动代码之前对 bss 部分进行了初始化。 - Ped7g
你从哪里得到了96字节?保留的缓冲区是126位或15字节?我不确定我是否理解正确。 - Asperger
哇,你真的很擅长解释事情。你无法想象我对你详细回答的感激之情。汇编语言确实很有趣,但也具有挑战性。你碰巧在CodeMentor.io吗? - Asperger
显示剩余2条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接