Machine-Level Programming/Arithmetic and Logic

Lesson 2.31,714 words

Arithmetic and Logic

The ALU instructions that compute on register and memory values: add, sub, and imul; the unary inc/dec/neg/not; the shifts sal/shr/sar; the bitwise and/or/xor; and lea reused as a fast arithmetic trick. Each binary operation also sets the condition-code flags CF, ZF, SF, and OF, which cmp and test compute without keeping a result.

╌╌╌╌

With operands in place, the machine computes. x86-64's integer arithmetic and logic instructions are a compact set: a few binary operations, a few unary ones, the shifts, and the bitwise logicals. Two facts make them more than a table to memorize. First, lea from the previous lesson doubles as an arithmetic instruction. Second, almost every arithmetic instruction quietly sets the condition-code flags as a side effect, and those flags are what the conditional jumps of the next lesson read.

Binary and unary operations

The binary operations take two operands and write the result to the destination, following the AT&T source-then-destination order. The destination accumulates: D becomes D op S, not S op D. This matters for the non-commutative sub.

InstructionEffect
addq S, D
subq S, D
imulq S, D
andq S, D (bitwise AND)
orq S, D (bitwise OR)
xorq S, D (bitwise XOR)

The operand order is worth stating plainly: subq %rax, %rbx computes , subtracting the source from the destination. Reading it as rax minus rbx inverts the sign of every program you analyze.

The unary operations take a single operand that is both source and destination.

InstructionEffect
incq D
decq D
negq D
notq D (bitwise complement)
arith.sassembly
# long arith(long x, long y): x in %rdi, y in %rsi
addq    %rsi, %rdi        # rdi = x + y
imulq   $3, %rdi          # rdi = 3*(x+y)
negq    %rdi              # rdi = -(3*(x+y))
movq    %rdi, %rax        # result to the return register
ret

By convention a procedure's result comes back in %rax, hence the final movq; the rule is made precise in the lesson on procedures.

lea as a fast arithmetic trick

The lea instruction from data movement computes Imm + Rb + Ri·S and stores it. Used on plain integers rather than addresses, it is a three-input adder with a built-in small multiplier, and it does not disturb the condition codes — a property compilers exploit to compute intermediate values without clobbering flags a later branch depends on.

leaq 6(%rdi,%rsi,4), %rax as arithmetic: it evaluates the address formula 6 + rdi + 4*rsi and stores the number, touching no memory and setting no flags.

So leaq (%rdi,%rdi,2), %rax triples a number (), leaq (,%rdi,8), %rax multiplies by 8, and leaq 1(%rdi,%rsi), %rax computes — each in one flag-preserving instruction.

A compiler chains these to avoid a general multiply entirely. The function long f(long x) { return 7 * x; } has a constant multiplier, and , so gcc computes it without imul:

times7.sassembly
# long f(long x): return 7 * x;  x in %rdi
leaq    0(,%rdi,8), %rax   # rax = 8*x   (scale-8, no base)
subq    %rdi, %rax         # rax = 8*x - x = 7*x
ret

Trace it with x = 5. The leaq computes into %rax; the subq then does , the answer. Two cheap instructions replaced a multiply, and the lea never touched the flags in between. The compiler picks this strength reduction whenever the constant factors into small shifts and adds; a factor like becomes leaq (%rdi,%rdi,4), %rax (that is ) followed by addq %rax, %rax (doubling to ). Reading such a pair backward — scale by 8, subtract one — is how you recover the constant the source multiplied by.

Shifts

The shift instructions move the bits of the destination left or right by a count given either as an immediate or in the single-byte register %cl. Left and right each come in the flavors the two's-complement representation demands.

  • sal k, D / shl k, Dshift left by k, filling with zeros. The two mnemonics are identical; left shift by k multiplies by .
  • shr k, Dlogical shift right, filling the vacated high bits with zeros. This is the right shift for unsigned values.
  • sar k, Darithmetic shift right, filling the high bits with copies of the sign bit. This is the right shift for signed values, preserving the sign.
Right shifts of the byte 1011 0010 by 2. Logical shr fills with zeros; arithmetic sar replicates the sign bit (1 here), preserving sign.

A variable count has a fixed home: the shift instructions read it only from the single-byte register %cl, never from an arbitrary register. So a shift by a computed amount first moves that amount into %cl, as in movb %r8b, %cl then shlq %cl, %rax. The immediate form shlq $3, %rax is the other option, used when the count is a constant.

The hardware then consults only the low bits of the count, masking it to the operand width: a 32-bit shift uses %cl mod 32 (its low 5 bits), a 64-bit shift uses %cl mod 64 (its low 6 bits). A count of 33 on a 32-bit value therefore shifts by 1, not 33, and never clears the whole register the way a naive reading would suggest. This masking is why a shift amount is always in range.

A quick trace shows the masking in action. Suppose %cl holds 33 and the instruction is shll %cl, %eax, a 32-bit shift. The hardware keeps only the low 5 bits of the count, and , whose low five bits are 00001 — a count of 1. So %eax shifts left by one, doubling, not by 33 (which would clear it to zero). The same %cl = 33 fed to a 64-bit shlq %cl, %rax keeps the low 6 bits, 100001, again a count of 1. The width of the operand decides how many count bits survive: 5 bits for 32-bit shifts, 6 for 64-bit, so a shift amount is always in range and can never blank a register the way a naive shift by 33 would.

The signed/unsigned split is what makes C's >> compile to sar for int and shr for unsigned.

Left shift is simpler: every bit slides toward the high end and zeros flow in at the bottom, which multiplies the value by . This is why a compiler turns x * 8 into sal $3, x — a shift is far cheaper than a multiply.

A left shift by 1. Each bit moves one place toward the high end, a zero enters at the low end, and the top bit falls off; the value doubles, so sal $1 is multiply-by-two.

Condition codes

Beyond their named registers, the processor keeps a few single-bit condition-code flags that record properties of the most recent arithmetic or logical result. The four that matter here are set as a side effect of nearly every ALU instruction.1

The four ALU condition-code flags. CF watches unsigned carry-out, ZF whether the result is zero, SF the sign bit, OF signed overflow.

A worked case shows all four flags at once and why signed and unsigned overflow are different events. Work in a single byte (8 bits) to keep the numbers small, and add 0x50 + 0x50 (decimal ).

addb $0x50, %al with %al = 0x50. The 8-bit result 0xA0 sets no carry (CF=0) but does set SF (top bit 1) and OF (two positives gave a negative). As unsigned this is 160, correct; as signed it is -96, an overflow.

Read the result three ways. The bit pattern is 0xA0 = 1010 0000. There was no carry out of the top bit, so CF = 0: as an unsigned sum, fits in a byte and is correct. But the top bit is now 1, so SF = 1, and two positive signed operands produced a negative-looking result — the definition of signed overflow — so OF = 1: as a signed sum, should be , which does not fit in the signed byte range , and the value reads as . The same addition on the same bits produces two different overflow verdicts, which is why the conditional jumps split into signed and unsigned families in the next lesson. The processor sets every flag on every add and lets the branch pick which ones matter.

Two design points keep the flags useful. First, lea does not set flags (noted above), nor do plain mov instructions. Second, the instructions that do set them are not uniform about it: add, sub, the logicals, neg, and the shifts all update the flags, not sets none at all, and inc/dec set every flag except CF, which they leave untouched — a classic x86 quirk that lets a loop counter step without destroying a carry. So a compiler can compute an address with lea, then test a value, and trust the flags it cares about to survive.

cmp and test

Two instructions exist purely to set flags without keeping a result — exactly what a conditional branch needs, since the question is how two values compare, not what their difference is.

  • cmpq S2, S1 computes , sets the flags accordingly, and discards the difference. Note the operand order: the flags describe , so cmpq %rsi, %rdi sets ZF when and SF/OF as if you had subtracted %rsi from %rdi.
  • testq S2, S1 computes , sets the flags, and discards the AND. The common idiom testq %rax, %rax sets ZF exactly when %rax is zero and SF when it is negative — a register sign/zero check costing no scratch register.
cmp is sub with the result thrown away. It subtracts source from destination only to set the four flags; both operands keep their values, so it compares without disturbing either, priming the next branch.
cmp.sassembly
# branch on whether x < y, x in %rdi, y in %rsi (signed)
cmpq    %rsi, %rdi        # flags describe rdi - rsi
jl      .Lless            # jump if x < y  (reads SF, OF)

The flags cmp and test deposit are read by the conditional jumps and set instructions, which translate the flags say %rdi < %rsi into a taken branch. That translation is the whole of the next lesson on control flow.

The operations one register cannot hold

The two-operand imulq S, D above keeps only the low 64 bits of a 64-by-64-bit product, which is all a C long multiply needs. But the true product of two 64-bit numbers is up to 128 bits, and CS:APP notes in passing that x86-64 has one-operand forms — mulq S (unsigned) and imulq S (signed) — that produce the full double-width result, splitting it across %rdx:%rax (high half in %rdx, low in %rax). Division is the mirror image: idivq S takes a 128-bit dividend in %rdx:%rax and leaves the quotient in %rax and the remainder in %rdx, which is why signed division is always preceded by cqto, an instruction whose only job is to sign-extend %rax into %rdx so the dividend is a proper 128-bit value. These fixed-register operands are a rare place where x86-64 pins specific registers, a direct inheritance from the 8086's accumulator model.2

The modern chapter the textbook predates is the BMI (Bit Manipulation Instruction) extensions Intel and AMD added around 2013. They turn common bit idioms that once took several instructions into one: andn computes ~x & y without a separate not; blsr clears the lowest set bit; tzcnt and lzcnt count trailing and leading zeros directly, which compilers emit for C's __builtin_ctz and __builtin_clz; and pdep/pext scatter and gather bits under a mask. A compiler targeting a recent -march will replace a hand-rolled bit-twiddle loop with one of these, so a shift-and-mask sequence you expect may appear as a single unfamiliar mnemonic. They obey the same operand grammar as the classic ALU instructions, only with more specialized semantics.3

Footnotes

  1. Bryant & O'Hallaron, CS:APP, §3.6.1 — Condition Codes: CF, ZF, SF, and OF are set by arithmetic and logical instructions; lea and mov leave them unchanged, and cmp/test set them without storing a result.
  2. Bryant & O'Hallaron, CS:APP, §3.5.5 — Special Arithmetic Operations: the one-operand mulq/imulq producing a 128-bit product in %rdx:%rax, and idivq dividing the %rdx:%rax pair with cqto sign-extending the dividend.
  3. Intel, Intel 64 and IA-32 Architectures Software Developer's Manual, Volume 1 (2023), §14.3 and the BMI1/BMI2 instruction listings — andn, blsr, tzcnt, lzcnt, pdep, and pext as single-instruction replacements for common bit-manipulation sequences.

╌╌ END ╌╌