3
\$\begingroup\$

I'm new to Assembly, and this is my very first "project" in Assembly. I wanted to store data (numbers) on the stack, then access and display them. Eventually, this "experiment" of mine grew to become a tiny library of 3 functions.

There are 3 functions:-

  • push_ASCII: For ASCII strings
  • push_int32_as_ASCII: For int32 values
  • clean_stack: To clean stack after the previous two functions

Brief Explanation

The objective of the library is to simplify printing data. Two functions push data onto the stack. All the pushed data can be printed using a single sys_write call. Finally, the third function cleans up the stack.

The two push functions pack data and start populating the stack from where the RSP is pointing till it runs out of bytes to populate.

The RSP decrements by 8 bytes (on a 64-bit architecture), and a total of ceil(total_length_of_string/8) push operations are performed. Thus, it's possible for the RSP to point at memory filled with 0's (null characters) after return from the first two functions.
The RBX register is used for this reason to offset the RSP while accessing the string on the stack.

If during a function call to the two push functions, there's existing string on the stack, the functions automatically pack data on to the stack to avoid any null character in the middle of the string on the stack.

After push_ASCII or push_int32_as_ASCII has been called, the stack is ready and a sys_write function can be performed using [RSP + RBX] for the address of the string, with R8 for the number of bytes to print. Once a sys_write has been performed, the pushed string can be popped using clean_stack. This function pops as many bytes as indicated by R8. Again since the RSP only moves by 8 bytes (64-bit architecture), it performs a total of ceil(R8/8) pop operations.

The Code

section .text
; =================================================================================
; PUSH_ASCII
; ---------------------------------------------------------------------------------
; Function to push existing string (ASCII) to the stack
;
; Input:-
; > RSI holding address to string to be pushed
; > RDX holding length of string
; > R8 holding the length of the string on the stack so far
; Output:-
; > (RSP + RBX) points to the beginning of the string on the stack
; > R8 holds the length of the string on the stack so far
;
; (Uses RAX, RBX, RCX, R9, and R10 registers internally)
; ---------------------------------------------------------------------------------
;
push_ASCII:
 pop r9 ; Since the stack is altered, the return address is saved
 
 lea rbx, [rsi + rdx - 1] ; Load address of the last char into RBX
 mov rcx, r8 ; Load existing length of string
 add r8, rdx ; Update value of R8 with current string length
 and rcx, 7 ; Perform modulo operation on RCX with 8
 test rcx, rcx 
 jz .b0 ; Jump to .b0 if RCX is a multiple of 8
 pop rax ; Pop previous stack push as it contains null characters
 neg rcx 
 add rcx, 8 ; Find number of null characters
 mov r10, rcx 
; Loop to right shift RAX to eliminate all null characters
.l0:
 shr rax, 8
 dec rcx
 test rcx, rcx
 jnz .l0
; end loop
 mov rcx, r10
 jmp .l1
 
.b0:
 xor rax, rax
 mov rcx, 8
; Loop to get each character of string backwards and put in RAX; then push to stack
.l1:
 shl rax, 8
 mov r10b, [rbx]
 add al, r10b
 dec rcx
 test rcx, rcx
 jnz .b1
 push rax
 xor rax, rax
 mov rcx, 8
.b1:
 dec rbx
 mov r10, rsi
 dec r10
 cmp rbx, r10
 jnz .l1
; end loop
; Calculate value for RBX; Check if RAX still has to be pushed
 and rcx, 7
 mov rbx, rcx
 test rcx, rcx
 jz .b2
; Loop to "left-adjust" RAX
.l2:
 shl rax, 8
 dec rcx
 test rcx, rcx
 jnz .l2
; end loop
; If RAX is not empty, push to stack
.b2:
 test rax, rax
 jz .b3
 
 push rax
.b3:
 jmp r9 ; Jump to previously stored return address
;
; =================================================================================
; =================================================================================
; PUSH_INT32_AS_ASCII
; ---------------------------------------------------------------------------------
; Function to push a 32-bit integer to the stack as an ASCII string
;
; Input:- 
; > RDI holding the number
; > R8 holding the length of the string on the stack so far
; Output:-
; > (RSP + RBX) points to the beginning of the string on the stack
; > R8 holds the length of the string on the stack so far
;
; (Uses RAX, RBX, RCX, RDI, R9, R10, and R11 internally)
; ---------------------------------------------------------------------------------
;
push_int32_as_ASCII: 
 pop r9 ; Store return address
 mov eax, edi ; Copy recieved 32-bit number
 mov ebx, 0xCCCCCCCD ; Agner Fog's magic number
 mov rcx, r8
 and rcx, 7 ; Perform modulo operation on RCX with 8
 test rcx, rcx
 jz .b0 ; Jump to .b0 if RCX is divisible by 8
 pop r10 ; Pop previous stack push as it contains null characters
 neg rcx
 add rcx, 8 ; Calculate number of null character
 mov r11, rcx
; Loop to right shift R10 to eliminate all null characters
.l0:
 shr r10, 8
 dec rcx
 test rcx, rcx
 jnz .l0
; end loop
 mov rcx, r11
 jmp .l1
.b0:
 xor r10, r10
 mov rcx, 8
; If number is negative, negate it
.b1:
 mov r11, rdi
 test r11, r11
 jns .l1
 neg eax
; Loop to convert decimal number to ASCII; push to stack
.l1:
 shl r10, 8
 mov edi, eax ; save original number
 mul ebx ; divide by 10 using agner fog's 'magic number'
 shr edx, 3 ;
 mov eax, edx ; store quotient for next loop
 lea edx, [edx*4 + edx] ; multiply by 10
 lea edx, [edx*2 - '0'] ; finish *10 and convert to ascii
 sub edi, edx ; subtract from original number to get remainder
 inc r8 ; Update R8
 lea r10, [r10 + rdi] ; Store current digit (in ASCII)
 dec rcx
 test rcx, rcx
 jnz .b2
 push r10
 xor r10, r10
 mov rcx, 8
.b2:
 test eax, eax
 jnz .l1
; end loop
; If given number was negative, add '-' sign
 test r11, r11
 jns .b3
 shl r10, 8
 lea r10, [r10 + '-']
 dec rcx
 inc r8
; Calculate value for RBX; Check if R10 still has to be pushed
.b3:
 and rcx, 7
 mov rbx, rcx
 test rcx, rcx
 jz .b4
; Loop to "left-adjust" R10
.l2:
 shl r10, 8
 dec rcx
 test rcx, rcx
 jnz .l2
; end loop 
; Push R10 if not empty
.b4:
 test r10, r10
 jz .b5
 push r10
.b5:
 jmp r9 ; Return to previously stored return address
;
; =================================================================================
; =================================================================================
; CLEAN_STACK
; ---------------------------------------------------------------------------------
; Function to "clean" the stack after push_string or push_uint32_as_ASCII calls
; Input:-
; > R8 holding the length of string pushed to the stack so far
; Output:-
; Nil
;
; (Uses RAX, RCX, and R9 internally)
; ---------------------------------------------------------------------------------
;
clean_stack:
 pop r9 ; Store return address
 test r8, r8 ; Check if R8 is 0 for early exit
 jz .b0
; Calculate number of pop operations ( ceil(R8/8) )
 mov rcx, r8
 shr rcx, 3 ; Divide RCX (holds same value as R8) by 8
 mov rax, r8
 and rax, 7
 test rax, rax
 jz .l0
 inc rcx
; Loop to pop stack 
.l0:
 pop rax
 dec rcx
 test rcx, rcx
 jnz .l0
; end loop
.b0:
 jmp r9 ; Return to previously-stored return address
;
; =================================================================================

The code works fine. I just want to get some expert opinions on the implementation or standard practices. But most importantly, if I'm doing something that's absolutely looked down upon in assembly coding (:p).

Link to the GitHub repo: https://github.com/ghost-1608/Assembly-Print-Header

Sep Roland
4,78317 silver badges28 bronze badges
asked Aug 7, 2023 at 18:18
\$\endgroup\$

2 Answers 2

2
\$\begingroup\$

Most arithmetic operations affect the flags

So for example after and rcx, 7, you don't need a test rcx, rcx to check whether rcx became zero, the and already set or reset the zero flag according to the result. By the way you may as well use and ecx, 7, saving 1 byte, which is not very significant but .. you may as well.

dec also sets the zero flag according to its result (but not the carry flag), so you don't need a test after it either. dec rcx may be slightly slower than sub rcx, 1 on some CPUs, for some time that quirk only affected irrelevant CPUs but unfortunately Intel reused some of those microarchitectures for their E-cores. sub rcx, 1 of course, also sets the zero flag according to its result.

Various 64-bit instructions can use the 32-bit variant

Since writes to 32-bit registers zero-extend to the corresponding 64-bit registers, often times you can use them and save a little bit of space. For example, xor rax, rax wastes a byte compared to xor eax, eax while being otherwise identical. mov rcx, 8 wastes two bytes compared to mov ecx, 8.

Saving a couple of bytes of code is not super important, but probably better on average, slightly reducing code fetch and potentially decoding time, depending on how dense the code is. There are some cases where you're better off wasting some space too though, it's not as simple as "smaller is always better".

ceil(R8/8)

If you add 7 before dividing by 8:

lea rcx, [r8 + 7]
shr rcx, 3

.. then you don't need to do a conditional increment if the bottom 3 bits of r8 were not zero, effectively adding 7 has done that.

There is an edge case if r8 can be very close to the maximum unsigned 64-bit integer, but for a string length that's not a reasonable case.

Popping the return address and jmp-ing to it

This seems to be a central idea of this code. It's a cute trick, but unfortunately not a good one, sorry. The problem is, it defeats return address prediction. Return address prediction allows calling and returning from functions to not be as slow as arbitrary (often hard to predict) indirect branches. Popping the return address and jumping to it has both a local effect of turning a fast ret into a usually slower jump to some arbitrary unknown address (as far as the CPU is concerned) (unless you repeatedly return to the same address), it also leaves the return address on the return address predictor stack so the next ret would be predicted to jump there, which is wrong, and so on if multiple returns are done in a sequence.

This kind of trick also wouldn't work with stack unwinding for exception handling, but you're not supplying stack unwinding information anyway.

answered Aug 7, 2023 at 22:37
\$\endgroup\$
2
  • \$\begingroup\$ Thanks a lot! Yes, I realised popping the return address and jmp-ing isn't the best idea. In fact, I used it only because I didn't find any other solution. How would you suggest structuring the library since I'm making changes to the stack at a global level? \$\endgroup\$ Commented Aug 18, 2023 at 14:21
  • 1
    \$\begingroup\$ @ghost a classic solution is requiring the caller to allocate the stack space, which the library function then writes data into. The caller would need to decide on the size of allocation, a typical solution is to just pick some reasonable limit eg 11 bytes (may as well round it up to 16) are sufficient to write the result of converting a 32-bit integer to decimal. \$\endgroup\$ Commented Aug 18, 2023 at 14:46
2
\$\begingroup\$

If during a function call to the two push functions, there's existing string on the stack, the functions automatically pack data on to the stack to avoid any null character in the middle of the string on the stack.

If it weren't for this ability to concatenate strings on the stack, I would have suggested much simpler code for the push_ASCII and push_int32_as_ASCII routines: aligning the start of the string at [RSP] (not requiring any RBX) and allowing any or some garbage bytes behind the string. A decent simplification in push_int32_as_ASCII is still possible if you would do the number conversion separately and have it followed by a call to push_ASCII that after all already exists for the purpose of pushing a string to the stack...

Although I understand the routines that use the actual push instruction, I don't agree with using a loop of pop instructions in the clean_stack code. Just calculate the number of bytes (multiple of 8) that you need to remove from the stack and adjust RSP with a single addition:

; IN (r8) OUT () MOD (rax,r9)
clean_stack:
 pop r9 ; Remove return address
 lea rax, [r8 + 7] ; Calculate the next higher multiple of 8 (unless it was
 and rax, -8 ; already a multiple of 8, then it stays unmodified)
 add rsp, rax ; Remove the string from the stack
 push r9 ; Restore return address
 ret

I just want to get some expert opinions on the implementation or standard practices. But most importantly, if I'm doing something that's absolutely looked down upon in assembly coding (:p).

  • You are using many loops, even for things that don't need one (see below).
  • You forget that you can read/write stack memory just like any other memory. You don't have to use push and pop persé.
  • Sometimes the code is hard to follow and should get streamlined, eliminating some jumps like this one: jmp .l1 .b0:.
  • Naming your labels .b0, .b1, etc makes it harder than necessary to understand the program, and especially labels like .l0 and .l1 need to be condemned for their poor readability.

Review

You don't need an actual loop in order to "to right shift RAX to eliminate all null characters". Simply take the number of null bytes and multiply by 8, then use shr rax, cl. You have a very similar loop "to left-adjust RAX" where you can apply the same loop elimination.

 and ecx, 7
 jz .b0
 pop rax ; Pop previous stack push as it contains null characters
 neg ecx 
 add ecx, 8 ; Find number of null characters
 shl ecx, 3 ; From byte to bits
 shr rax, cl ; Right shift RAX to eliminate all null characters
 shr ecx, 3 ; From bits to bytes
 jmp .l1
.b0:
 xor eax, eax
 mov ecx, 8
.l1:

.l1:
 shl rax, 8
 mov r10b, [rbx]
 add al, r10b

Instead of using the R10B register, you could simply write: mov al, [rbx]. Of course, if you care about efficiency then write:

.l1:
 shl rax, 8
 movzx r10, byte [rbx]
 or rax, r10

.b1:
 dec rbx
 mov r10, rsi
 dec r10
 cmp rbx, r10
 jnz .l1

Here you could replace the pair mov r10, rsi dec r10 by the single lea r10, [rsi - 1] instruction, but since the intent is to continue the loop for as long as the address in RBX falls within the string at RSI, this code should become:

.b1:
 dec rbx
 cmp rbx, rsi
 jae .l1 ; While RBX is AboveOrEqual to RSI, continue
answered Aug 8, 2023 at 21:38
\$\endgroup\$
2
  • \$\begingroup\$ Thanks for the review! You talked about the choice of labels I used. What would you suggest instead? I agree this kind of labelling is hard to follow (and hard to to deal with while editing the code) \$\endgroup\$ Commented Aug 18, 2023 at 14:19
  • 1
    \$\begingroup\$ @ghost Usually one would suggest to choose meaningful names for labels. eg. in PUSH_INT32_AS_ASCII you could change .b3: into .IsPositive:. However, once you eliminate many of the branches (requiring less labels) and start re-using existing code (making calls), you will notice that many functions that you write will be limited in length, say 25 instructions or so. What I then use are anonymous local labels like .a:, .b:, .c: etc. One trick to make editing a bit easier: I label from the top using .a:, .b:, .c: etc. and I label from the bottom using .z:, .y:, .x: etc. \$\endgroup\$ Commented Aug 20, 2023 at 9:50

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.