在汇编语言编程中,如何打印一个整数而不使用c库中的printf函数?(itoa,将整数转换为十进制ASCII字符串)

30
有人能告诉我纯汇编代码如何以十进制格式显示寄存器中的值吗?请不要建议使用printf hack,然后再用gcc编译。
描述:
我做了一些研究和NASM实验,发现可以使用c库中的printf函数打印整数。我通过使用GCC编译器编译目标文件来实现这个目的,一切都还好。
然而,我想实现的是以十进制形式打印任何寄存器中存储的值。
我做了一些研究,并发现DOS命令行的中断向量021h可以在ah寄存器中为2或9时显示字符串和字符,而数据则在dx中。
结论:
我找到的所有示例都没有展示如何以十进制形式显示寄存器内容的值,而不使用C库的printf。有人知道如何在汇编中实现吗?

它是什么类型的数?浮点数吗? - slashingweapon
为简单起见,假设它是一个无符号整数。如果我有dh中的00000101h,如何显示5?如果我有dh中的00000111h,如何显示7? - Kaustav Majumder
我在Windows 7 (x86)上使用NASM,并使用默认的“com”输出格式! - Kaustav Majumder
1
一个16位DOS版本:https://dev59.com/zlLTa4cB1Zd3GeqPXBNP - Ciro Santilli OurBigBook.com
1
可能是在Linux上使用汇编输出整数的重复问题。 - Ciro Santilli OurBigBook.com
显示剩余4条评论
5个回答

22

您需要编写一个二进制转十进制的函数,并使用十进制数字生成“数字字符”以便打印。

您需要假设某个地方会在您选择的输出设备上打印字符。称此子程序为“print_character”。假设它接受EAX中的字符代码并保留所有寄存器。(如果没有这样的子程序,您会有一个额外的问题,应该成为另一个问题的基础)。

如果您在一个寄存器(例如EAX)中有一个数字的二进制代码(例如0到9的值),则可以通过将ASCII码的“零”字符代码添加到该寄存器来将该值转换为表示该数字的字符。这很简单,只需执行以下操作:

       add     eax, 0x30    ; convert digit in EAX to corresponding character digit

然后您可以调用print_character来打印数字字符码。

要输出任意值,您需要挑选数字并将它们打印出来。

挑选数字基本上需要使用十的幂。最简单的方法是使用一个十的幂,例如10本身。想象一下我们有一个除以10的程序,它将EAX中的值取出,并在EDX中产生商,在EAX中产生余数。如何实现这样的程序,我把它留给你来练习。

然后,一个正确思路的简单程序是为值可能具有的所有数字产生一个数字。32位寄存器可存储4亿个值,因此您可以打印10个数字。所以:

         mov    eax, valuetoprint
         mov    ecx, 10        ;  digit count to produce
loop:    call   dividebyten
         add    eax, 0x30
         call   printcharacter
         mov    eax, edx
         dec    ecx
         jne    loop

这个方法可以运行...但是会以相反的顺序打印出数字. 哎呀!没关系,我们可以利用下推栈来存储生成的数字,然后按照相反的顺序将它们弹出:

         mov    eax, valuetoprint
         mov    ecx, 10        ;  digit count to generate
loop1:   call   dividebyten
         add    eax, 0x30
         push   eax
         mov    eax, edx
         dec    ecx
         jne    loop1
         mov    ecx, 10        ;  digit count to print
loop2:   pop    eax
         call   printcharacter
         dec    ecx
         jne    loop2

留给读者的练习:消除前导零。另外,由于我们要将数字字符写入内存,而不是写入堆栈,因此我们可以将它们写入缓冲区,然后打印缓冲区内容。同样留给读者的练习。


这比 call _printf 真的更快吗? - Beyondo
@XStylish:很可能:如果你所说的printf是一个接受格式字符串并格式化十进制数字的方法,那么肯定是这样的,因为printf例程必须解释格式字符串并生成数字,而且只能生成数字。如果你打算为屏幕输出产生输出,速度可能并不重要,因为人们阅读得很慢。如果你要将字符串写入文件,你可能想要乘以".1"并取小数部分-而不是除以10。 - Ira Baxter
应该是“乘以固定点值0.1并取小数部分,而不是除以10,以提高转换速度。” - Ira Baxter

13

你需要手动将二进制整数转换为ASCII十进制数字的字符串/数组。 ASCII数字由范围在'0'(0x30)到'9'(0x39)之间的1字节整数表示。http://www.asciitable.com/

对于像十六进制这样的2的幂基数,请参见如何将二进制整数转换为十六进制字符串? 在二进制和2的幂基数之间转换可以进行更多的优化和简化,因为每组位独立地映射到一个十六进制/八进制数字。


大多数操作系统/环境没有一个能够接受整数并将其转换为十进制的系统调用。在向操作系统发送字节之前,或者自己复制到视频内存中,或者在视频内存中绘制相应的字形之前,您必须自己完成这个过程...
迄今为止最有效的方法是进行一次完整字符串的系统调用,因为写入8个字节的系统调用基本上与写入1个字节的成本相同。
这意味着我们需要一个缓冲区,但这并不会增加我们的复杂性。2 ^ 32-1只有4294967295,仅有10个十进制数字。我们的缓冲区不需要很大,所以我们可以使用堆栈。
通常算法按LSD(最低有效位)顺序生成数字。由于打印顺序是MSD(最高有效位)顺序,因此我们只需从缓冲区末尾开始向后工作即可。对于打印或复制到其他地方,只需跟踪它开始的位置,并且不要费心将其放到固定缓冲区的开头。无需混乱地推/弹出任何内容来反转任何内容,只需首先反向生成它。
char *itoa_end(unsigned long val, char *p_end) {
  const unsigned base = 10;
  char *p = p_end;
  do {
    *--p = (val % base) + '0';
    val /= base;
  } while(val);                  // runs at least once to print '0' for val=0.

  // write(1, p,  p_end-p);
  return p;  // let the caller know where the leading digit is
}

gcc/clang非常出色,使用魔数乘法(使用链接1中提到的方法)来代替div以实现高效的除以10操作。(可使用Godbolt编译器浏览器查看汇编输出)。
这个 代码审查问答 有一个高效的 NASM 版本,它将字符串累加到一个 8 字节寄存器中,而不是累加到内存中,可以直接将字符串存储到所需位置,避免了额外的复制。

处理带符号整数:

在无符号绝对值上使用此算法(if(val<0) val=-val;)。如果原始输入为负数,则在完成后在最前面添加一个'-'。例如,-10 使用10 运行此算法,生成2个ASCII字节。然后将'-'存储为字符串的第三个字节。


这是一个简单的注释版NASM版本,使用div(慢但代码更短)适用于32位无符号整数和Linux write系统调用。只需通过将寄存器从rcx改为ecx就可以轻松将其移植到32位模式代码。但是add rsp,24将变成add esp,20,因为push ecx仅为4个字节,而不是8个字节。(除非你将其制作成宏或仅供内部使用的函数,否则应保存/恢复esi以遵循通常的32位调用约定。)
系统调用部分是针对64位Linux的。如果你的系统不同,可以替换为相应的内容,例如,在32位Linux上调用VDSO页面以进行高效的系统调用,或直接使用int 0x80进行低效的系统调用。请参见Unix/Linux上32位和64位系统调用的调用约定。或者查看另一个问题的rkhb回答,其中包含一个32位int 0x80版本,其工作方式相同。
如果你只需要字符串而不是打印它,rsi会指向离开循环后的第一个数字。你可以将它从tmp缓冲区复制到实际需要的位置的开头。或者,如果你直接将其生成到最终目标中(例如传递指针参数),你可以在达到留给它的空间前面时用前导零填充。除非你总是用零填充到固定宽度,否则没有简单的方法在开始之前找出它将有多少位数。
ALIGN 16
; void print_uint32(uint32_t edi)
; x86-64 System V calling convention.  Clobbers RSI, RCX, RDX, RAX.
; optimized for simplicity and compactness, not speed (DIV is slow)
global print_uint32
print_uint32:
    mov    eax, edi              ; function arg

    mov    ecx, 0xa              ; base 10
    push   rcx                   ; ASCII newline '\n' = 0xa = base
    mov    rsi, rsp
    sub    rsp, 16               ; not needed on 64-bit Linux, the red-zone is big enough.  Change the LEA below if you remove this.

;;; rsi is pointing at '\n' on the stack, with 16B of "allocated" space below that.
.toascii_digit:                ; do {
    xor    edx, edx
    div    ecx                   ; edx=remainder = low digit = 0..9.  eax/=10
                                 ;; DIV IS SLOW.  use a multiplicative inverse if performance is relevant.
    add    edx, '0'
    dec    rsi                 ; store digits in MSD-first printing order, working backwards from the end of the string
    mov    [rsi], dl

    test   eax,eax             ; } while(x);
    jnz  .toascii_digit
;;; rsi points to the first digit


    mov    eax, 1               ; __NR_write from /usr/include/asm/unistd_64.h
    mov    edi, 1               ; fd = STDOUT_FILENO
    ; pointer already in RSI    ; buf = last digit stored = most significant
    lea    edx, [rsp+16 + 1]    ; yes, it's safe to truncate pointers before subtracting to find length.
    sub    edx, esi             ; RDX = length = end-start, including the \n
    syscall                     ; write(1, string /*RSI*/,  digits + 1)

    add  rsp, 24                ; (in 32-bit: add esp,20) undo the push and the buffer reservation
    ret
公共领域。 随意将其复制/粘贴到您正在处理的任何内容中。 如果它出现故障,您将保留两个部分。 (如果性能很重要,请参见下面的链接;您将需要一个乘法逆元而不是div。)
这里是调用它的代码,循环计数至0(包括0)。 将其放在同一文件中很方便。
ALIGN 16
global _start
_start:
    mov    ebx, 100
.repeat:
    lea    edi, [rbx + 0]      ; put +whatever constant you want here.
    call   print_uint32
    dec    ebx
    jge   .repeat


    xor    edi, edi
    mov    eax, 231
    syscall                             ; sys_exit_group(0)

使用指令进行汇编和链接

yasm -felf64 -Worphan-labels -gdwarf2 print-integer.asm &&
ld -o print-integer print-integer.o

./print_integer
100
99
...
1
0

使用strace查看此程序所做的唯一系统调用是write()exit()。(另请参阅标签维基底部的gdb /调试提示以及其他链接。)

相关:


0

无法评论,所以我用回复的方式发表。 @Ira Baxter,完美的答案。我只想补充一点,您不需要像您发布的那样将寄存器cx设置为值10并进行10次除法。只需将ax中的数字除以2,直到"ax==0"即可。

loop1: call dividebyten
       ...
       cmp ax,0
       jnz loop1

你还需要存储原始数字中有多少位数字。

       mov cx,0
loop1: call dividebyten
       inc cx

无论如何,Ira Baxter帮了我,有几种方法可以优化代码 :)

这不仅涉及优化,还涉及格式。当您想打印数字54时,您想要打印54而不是0000000054 :)


0

我猜你想要将值打印到stdout吧?如果是这种情况,那么你需要使用系统调用来实现。系统调用是依赖于操作系统的。

例如,在Linux下: Linux系统调用列表

教程中的Hello World程序可能会帮助你获得一些见解。


谢谢您的建议!我目前正在使用Windows 7(x86)操作系统工作!我需要通过ALP考试,将在实验室中在Win环境下组装代码!不过我会查看教程的!非常感谢! :) - Kaustav Majumder

0

1-9是1-9。之后,必须进行某些我也不知道的转换。假设你在AX(EAX)中有一个41H,并且你想打印一个65,而不是 'A',而不需要执行一些服务调用。我认为你需要打印一个字符表示6和5,无论那是什么。一定有一个常数可以添加到那里。你需要一个模运算符(无论你在汇编中如何执行),并循环所有数字。

不确定,但这是我的猜测。


1
是的,没错。在ASCII中,'0'到'9'的字符编码是连续的,所以你可以通过计算6 + '0'来得到'6'。即使用div或其他方法获取余数,然后将edx加上'0'并将该字节存储到缓冲区中。'0' = 0x30,但大多数汇编器接受字符常量,因此用这种方式编写代码更清晰。(也可以使用OR / AND而不是ADD / SUB,这也有效,因为0x30没有任何低4位设置。) - Peter Cordes

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