x86-64 assembly from the ground up

Numbers into text

A byte holding a digit is a character, not a number. Dividing by ten peels the digits off one at a time, and a buffer filled from the end turns an integer into a line of text.

Part 10 of 12

Advanced20 min readBack to Courses

A digit is a character

The character zero is the byte forty-eight. One is forty-nine and so on up to nine at fifty-seven, so a digit is its value plus forty-eight — an addition the processor does not do for you. add al, 48 and add al, '0' are the same instruction with the same effect.

This is why the write call cannot print a number. It sends bytes to a terminal that decodes them as text, so a byte holding two hundred and twenty-five is not the digits two two five: it is a single character nobody can read. Turning a number into the text of that number is a routine every program that prints numbers has to have.

The mapping is a convention rather than a law of the machine. Forty-eight is zero because ASCII says so and every terminal in the chain agrees; the processor only adds and stores. That is also why adding the character literal is clearer than adding forty-eight: the assembler turns the literal into the same number, and the line says what it means.

Ten gives you the last digit

Dividing by ten answers two questions at once. The remainder is the digit in the ones place, and the quotient is everything to the left of it. Repeat the division on the quotient and the next digit falls out, then the next, until the quotient reaches zero.

The digits therefore arrive in the wrong order — last first. The trick that saves a second pass is to fill the buffer from the right: a pointer starts just past the last slot, each digit is written one place further left, and when the loop ends the digits are already in reading order and the pointer is sitting on the first of them.

The length is then the distance from that pointer to the end of the buffer, which is two addresses subtracted from each other. div is the unsigned form here, so xor edx, edx clears the high half before every division and the remainder in edx is the digit you want in dl.

The loop that builds the number backwards

_start:
  ; buf is eight empty slots followed by a newline byte
  mov eax, 2025
  lea rsi, [buf + 8]    ; one past the last digit slot
  mov ecx, 10
.digit:
  xor edx, edx          ; the high half must start at zero
  div ecx               ; quotient in eax, remainder in edx
  add dl, '0'           ; the remainder is this digit
  dec rsi
  mov [rsi], dl         ; store it, last digit first
  test eax, eax
  jnz .digit            ; stop once the quotient is zero

  ; rsi now points at the first digit: that is the message

Print a number as digits

Run it, then change 2025 to 7, then to 65535. The length is worked out from where the digits ended up, so every one of those prints correctly with no other edit.

Output
Click "Run" to execute your code

Change the number to seven and run it: the same loop produces one digit and the pointer lands one slot further right, so the length shrinks with it. Nothing in the routine is written for a particular number of digits.

The length is the distance between two addresses

When the digit loop finishes, rsi points at the first character that was written and the buffer has a known end. The byte count is one address minus the other, which is why the routine never counts digits. lea rdx, [buf + 9] puts the end address in a register and sub rdx, rsi turns the pair into a length.

The newline is part of that distance because it sits at the end of the same buffer. That is a deliberate convenience: one write sends the digits and the line ending together, rather than needing a second call with a count of one.

The same trick works for any slice. Point at the first byte of a word and subtract from the address after the last byte, and the result is the length of that word.

Zero is a digit too

The loop tests the quotient at the bottom, not the top, so a value of zero still runs once. Dividing zero by ten gives a quotient of zero and a remainder of zero, the remainder becomes the character 0, and the test then stops the loop. That is exactly the output you want.

A while loop written the other way round would skip the body entirely for zero and print an empty line. The do-while shape is not an accident here; it is what makes zero behave like every other number.

Negative values need one extra step before the loop. A program prints a minus sign, makes the value positive with neg, and then runs the same division. The digits are the same; only the sign is handled outside.

Three conversion mistakes. A missing xor edx, edx before each division leaves a remainder in the high half and produces a wrong quotient. A buffer with fewer slots than the number has digits runs out of room before the digits do. And forgetting that the loop is a do-while makes zero look like a special case when it is not.

Try these next

  • Convert zero and confirm the routine prints one character, not an empty line.
  • Convert 65535 and count the five digits in the output.
  • Add a minus sign for a negative value by printing it before the magnitude.

One routine, two numbers

Run it. The same routine prints 2025 and then 7, rebuilding the digits each time from wherever the pointer stops. Then change 7 to 65535 and 2025 to 9, and check that both lines follow without a new buffer.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does the byte that holds the character 0 contain?