Doing the Math

Moving data around is only useful if you eventually compute something with it. This lesson covers the basic integer arithmetic instructions and, just as importantly, how the arithmetic you write in C++ maps onto them. Once you can look at a + b and predict the add, you are reading rather than decoding.

The arithmetic instructions follow the same mnemonic dst, src shape as mov, with one twist: most of them are two-operand and in-place. An instruction like add eax, edx means eax = eax + edx. The destination is also one of the inputs and gets overwritten with the result. Keep that in mind and the listings make sense.

Addition: add

Start with the simplest possible case:

int compute(int a, int b)
{
    return a + b;
}

At -O0:

compute(int, int):
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -4[rbp], edi
	mov	DWORD PTR -8[rbp], esi
	mov	edx, DWORD PTR -4[rbp]
	mov	eax, DWORD PTR -8[rbp]
	add	eax, edx
	pop	rbp
	ret

Skim past the now-familiar plumbing and the one line that does the work is add eax, edx: "eax = eax + edx." The two arguments were loaded into eax and edx, added together with the sum landing in eax, and since eax is the return register the function is done. The + in your C++ became exactly one add.

Subtraction: sub

Subtraction is the mirror image. At -O2, where the plumbing is gone:

int diff(int a, int b)
{
    return a - b;
}
diff(int, int):
	mov	eax, edi
	sub	eax, esi
	ret

The first argument a (in edi) is copied to eax, then sub eax, esi computes eax = eax - esi, i.e. a - b. Order matters here in a way it did not for add: sub subtracts the source from the destination, so the compiler must arrange for the minuend (a) to be in the destination. Watching which operand ends up where is how you confirm you have the direction right.

Multiplication: imul

You already met imul ("integer multiply") in Chapter 1. In its two-operand form it works just like add and sub:

int product(int a, int b)
{
    return a * b;
}
product(int, int):
	mov	eax, edi
	imul	eax, esi
	ret

imul eax, esi computes eax = eax * esi. The i stands for "integer" (specifically signed); there is a separate mul for certain unsigned cases, but for ordinary int and unsigned multiplication you will almost always see imul.

imul has a handy three-operand form too, used when multiplying by a constant:

int scale(int n)
{
    return n * 100;
}
scale(int):
	imul	eax, edi, 100
	ret

Read imul eax, edi, 100 as "eax = edi * 100." The three-operand version takes a source register and an immediate and writes the product to a separate destination, so it does not have to overwrite an input. It is the one arithmetic instruction that breaks the in-place pattern, and it exists precisely for the common "multiply a value by a literal" case.

Increment, Decrement, and Negate

Three single-operand instructions round out the basics:

  • inc dst adds 1: dst = dst + 1.
  • dec dst subtracts 1: dst = dst - 1.
  • neg dst flips the sign: dst = -dst.

neg shows up reliably for unary minus:

int negate(int n)
{
    return -n;
}
negate(int):
	mov	eax, edi
	neg	eax
	ret

The argument is copied to eax and neg eax negates it. Clean and direct.

inc and dec are interesting because the compiler does not always use them, even for n + 1. Watch:

int bump(int n)
{
    return n + 1;
}

At -O0 it does what you would guess:

bump(int):
	push	rbp
	mov	rbp, rsp
	mov	DWORD PTR -4[rbp], edi
	mov	eax, DWORD PTR -4[rbp]
	add	eax, 1
	pop	rbp
	ret

Note that even here it chose add eax, 1 rather than inc eax. And at -O2 it does something different again:

bump(int):
	lea	eax, 1[rdi]
	ret

That lea is not arithmetic in the usual sense at all; it is the address-calculation instruction, repurposed to compute rdi + 1 in a single step without even touching eax as an input. Modern compilers often prefer add and lea over inc/dec for subtle performance reasons on contemporary CPUs. You will still encounter inc and dec in plenty of code, so know what they mean, but do not be surprised when n + 1 compiles to add or lea instead. The whole story of lea is the final lesson of this chapter.

When Several Operations Combine

Real expressions chain these instructions. Here is one that uses three at once:

int evaluate(int a, int b)
{
    return a * b - (a + b);
}

At -O2:

evaluate(int, int):
	mov	eax, edi
	add	edi, esi
	imul	eax, esi
	sub	eax, esi
	ret

Trace it carefully and you can see the compiler being clever. a is in edi, b in esi. It copies a into eax, then computes edi = a + b (add edi, esi), then eax = a * b (imul eax, esi)... but wait, the final sub is sub eax, esi, not sub eax, edi. The optimizer rearranged the algebra: it is computing a*b - a - b step by step rather than literally forming (a + b) and subtracting it. The result is identical, but the instruction sequence is the compiler's, not a word-for-word translation of your parentheses. Spotting that the assembly computes the same value by a different route is exactly the kind of reading this course builds toward.

Try It Yourself

The exercise for this lesson has you write a small expression mixing multiplication, addition, and subtraction, then read the assembly panel to find the imul, add, and sub instructions that carry it out. Reading the arithmetic for an expression you wrote yourself is the fastest way to lock in the mapping from +, -, and * to their instructions.

Key Takeaways

  • Most arithmetic instructions are two-operand and in-place: add eax, edx means eax = eax + edx.
  • add, sub, and imul implement C++ +, -, and *. With sub, the source is subtracted from the destination, so operand order matters.
  • imul also has a three-operand form, imul dst, src, imm, used for multiplying by a constant.
  • inc, dec, and neg are single-operand: add 1, subtract 1, and negate. neg is the usual translation of unary minus.
  • Compilers often render n + 1 as add or even lea rather than inc, for performance reasons.
  • The optimizer may reorder or rewrite the algebra of an expression; the assembly computes the same value, not necessarily by your exact steps.