Hailstone Sequence in NASM
Multiplying by 3

The multiplication part seems overly complicated. The problem is, mul doesn't take an immediate, and the register that I want to multiply is ebx, not eax, so I need to do a couple movs before I can multiply.

This is all true, but based on the premise that the mul instruction must be used. Here are a couple of alternatives:

imul ebx, ebx, 3, listed in the manual as a signed multiplication, but that makes no difference, because only the low half of the product is used.
lea ebx, [ebx + 2*ebx], even the +1 can be merged into it: lea ebx, [ebx + 2*ebx + 1]. As a reminder, lea evaluates the address on the right and stores it in the destination register, it does not access memory despite the square-brackets syntax. 3-component lea takes 3 cycles on some processors (eg Haswell, Skylake), making it slightly slower than a 2-component lea and a separate inc. 3-component lea is good on Ryzen.

Dividing by 10
The simplest way is of course to use the div instruction, but that's not the fastest way, and it's not what a compiler would do. Here is a faster way, similar to how compilers do it, based on multiplying by a fixed-point reciprocal of 10 (namely 235 / 10, the difference between 235 and 232 is compensated for by shifting right by 3, the remaining division by 232 is implicit by taking the high half of the output of mul).
; calculate quotient ecx/10
mov eax, 0xCCCCCCCD
mul ecx
shr edx, 3
mov eax, ecx
mov ecx, edx
; calculate remainder as n - 10*(n/10)
lea edx, [edx + 4*edx]
add edx, edx
sub eax, edx

push edx in print_int
This will put 4 bytes on the stack for every character of the decimal representation of the integer, 1 actual char and 3 zeroes as filler. That looks fine when printed because a zero does not look like anything, so I'm not sure if this should be classed as a bug, but it just seems like an odd thing to do. The characters could be written to some buffer byte-by-byte, with a store and decrementing the pointer, then there would not be zeroes mixed in. A similar &quot;subtract pointers to find the length&quot;-trick could be used, that's a good trick.
Small tricks

mov edx, 0  ; Zeroing out edx for div


That's fine but xor edx, edx is preferred, unless the flags must be preserved.

    jmp .end
.even


Given that n is odd, 3n+1 is even, so you could omit the jump and have the flow of execution fall straight into the &quot;even&quot; case. Of course that means that not all integers in the sequence are printed, so maybe you can't use this trick, depending on what you want from the program.
If skipping some numbers to accelerate the sequence is OK, here is an other trick for that: skip a sequence of even numbers all at once by counting the trailing zeroes and shifting them all out.
tzcnt ecx, ebx
shr ebx, cl


   mov esp, ebp
   pop ebp


If you want (it doesn't make a significant difference, so it's mostly personal preference), you can use leave instead of this pair of instructions. Pairing the leave with enter is not recommended because enter is slow, but leave itself is OK. GCC likes to use leave when it makes sense, but Clang and MSVC don't.

       cmp ecx, 0
       jne .loop


That's fine, but there are a couple of alternatives that you may find interesting:


test ecx, ecx
jne .loop

Saves a byte, thanks to not having to encode the zero explicitly.

jecxz .loop

This special case can be used because ecx is used. Only 2 bytes instead of 5 or 4. However, unlike a fusible arith/branch pair, this costs 2 µops on Intel processors. On Ryzen there is no downside.