Blog › ICP guides
Assembly developer on retainer: x86 LOOP register conflicts, calling conventions, BCD instructions, and assembly language on monthly retainer
October 2, 2026 · ~14 min read
An assembly developer was maintaining firmware for a legacy industrial controller written in x86 assembly. The firmware unpacked packed BCD (Binary Coded Decimal) digits from status bytes read from hardware registers. The developer wrote a subroutine called unpack_bcd that used the LOOP instruction to iterate over input bytes: MOV ECX, byte_count; then a loop_start label, processing instructions, then LOOP loop_start.
Inside the loop body, ECX was also incremented with INC ECX to advance through the input buffer. The LOOP instruction decrements ECX and jumps back to loop_start if ECX is not zero. After the INC ECX inside the loop body advanced the data pointer, LOOP decremented ECX back by one, making the effective pointer stationary. After 2 iterations, ECX still pointed to the first input byte, not the third.
The subroutine produced 3 wrong BCD digit pairs — all decoded from the first input byte, not the expected sequence. Fix: moved the loop counter to EBX (MOV EBX, byte_count), used ESI as the data pointer (MOV ESI, buffer_address; INC ESI per iteration), and replaced LOOP with DEC EBX; JNZ loop_start. Wrong BCD digit pairs: 3 → 0.
The LOOP instruction is dangerous for exactly this reason: it has an implicit dependency on ECX that is invisible in the instruction name. Any code in the loop body that reads or writes ECX — for pointer arithmetic, for calling conventions, for REP string instructions — will corrupt either the loop counter or the data pointer. The ECX conflict does not produce a fault or a diagnostic message; it produces silently wrong output, and the wrong output may only appear in specific hardware status byte formats, leaving the bug latent for months.
Assembly overview: the language directly above machine code
x86 assembly language is a symbolic representation of the Intel x86 instruction set architecture, first introduced with the 8086 in 1978 and expanded to 32-bit (IA-32) with the 80386 in 1985 and to 64-bit (x86_64 / AMD64) with the AMD Opteron in 2003. Assembly gives the programmer direct access to CPU registers, memory addresses, and hardware I/O ports — the only abstraction it provides over machine code is mnemonic names for opcodes and symbolic labels for addresses.
Assembly retainer work today exists in three main domains. Embedded firmware: bootloaders, BIOS/UEFI, industrial controllers, medical devices, and automotive ECUs where register-level hardware access or strict code size constraints require assembly. Performance-critical inner loops: cryptography primitives, compression algorithms, video codec transforms, and scientific computation kernels where a C compiler cannot produce the exact instruction sequence needed. Legacy system maintenance: DOS applications, early Windows drivers, industrial automation programs from the 1980s and 1990s where the original source code is in assembly and the cost of translation to a higher-level language exceeds the cost of continued maintenance.
The three major x86 assemblers are: MASM (Microsoft Macro Assembler, ships with Visual Studio, Intel syntax, Windows-native), NASM (Netwide Assembler, cross-platform, Intel syntax, more portable), and GAS (GNU Assembler, part of binutils, AT&T syntax by default — operand order reversed: source before destination, register names prefixed with %). FASM (Flat Assembler) and YASM are further alternatives. The AT&T vs Intel syntax distinction is a persistent source of confusion when reading code from different toolchains: in Intel syntax MOV EAX, EBX copies EBX into EAX; in AT&T syntax the equivalent is movl %ebx, %eax with source and destination reversed.
The assembler toolchain choice determines more than syntax: MASM supports high-level constructs like .IF, .WHILE, and INVOKE that generate boilerplate calling convention code; NASM is strictly low-level and portable; GAS is the assembler for Linux kernel modules and inline assembly in GCC. Retainer agreements for assembly work must specify which toolchain is in scope, because macros, directives, and calling convention helpers differ entirely between them.
x86 registers, the LOOP instruction, and implicit register dependencies
The x86 general-purpose registers have conventional uses that create implicit dependencies throughout the instruction set. EAX (accumulator): arithmetic results, function return values in cdecl and stdcall. EBX (base): general purpose, callee-saved in cdecl — the called function must preserve it. ECX (count): implicit counter for LOOP, LOOPE, LOOPNE, REP, REPZ, and REPNZ; also the shift/rotate count register when CL is specified in shift instructions. EDX (data): high half of 64-bit multiply and divide results; I/O port address for IN and OUT instructions. ESI (source index): source pointer for string instructions MOVS, CMPS, and LODS. EDI (destination index): destination pointer for string instructions MOVS, STOS, and SCAS. ESP (stack pointer): implicitly used by PUSH, POP, CALL, and RET. EBP (base pointer): frame pointer in cdecl.
The LOOP instruction (opcode 0xE2) decrements ECX by 1 and jumps to the short label if ECX is not zero after the decrement. LOOPE (also LOOPZ, opcode 0xE1) additionally requires ZF=1 to take the jump. LOOPNE (also LOOPNZ, opcode 0xE0) additionally requires ZF=0. The REP prefix repeats a string instruction (MOVS, STOS, CMPS, LODS, SCAS) ECX times, decrementing ECX on each iteration. REPE/REPZ and REPNE/REPNZ add zero-flag conditions.
Any instruction in a LOOP-controlled body that uses ECX for any other purpose will corrupt either the loop count or the data. The corruption mechanism depends on the direction: if the loop body increments ECX (advancing a pointer), LOOP decrements it back, making the pointer stationary. If the loop body decrements ECX (advancing a pointer in reverse or consuming a count), LOOP decrements it again, making the effective decrement two per iteration. If the loop body calls a function that returns a value in ECX (some calling conventions), LOOP uses that return value as the new iteration count.
The work log entry for a LOOP register conflict must name the subroutine, the conflicting ECX use inside the body, the iteration count at which the pointer and counter diverged, the symptom (wrong data, premature loop exit, infinite loop or near-infinite loop), and the fix. The canonical fix is: assign the loop counter to EBX or EDX; assign the data pointer to ESI or EDI; replace LOOP label with DEC EBX; JNZ label (or the equivalent for the chosen counter register); use INC ESI or ADD ESI, element_size for pointer advancement. This makes all register uses explicit and eliminates the implicit ECX dependency entirely.
Calling conventions, stack frames, and BCD arithmetic instructions
x86 calling conventions define which registers are caller-saved (may be destroyed by the called function; the caller must save them if needed after the call) and which are callee-saved (must be preserved by the called function; the callee must save and restore them). cdecl (the C default for 32-bit x86): caller pushes arguments right-to-left onto the stack before the call; callee returns the result in EAX (or EAX:EDX for 64-bit values); caller cleans the stack after the call; EBX, ESI, EDI, and EBP are callee-saved. stdcall (Windows API): callee cleans the stack; same callee-saved registers as cdecl. fastcall: first two integer arguments are passed in ECX and EDX — which immediately conflicts with LOOP’s implicit ECX use and with MUL/DIV’s implicit EDX use.
x86_64 System V AMD64 ABI (Linux and macOS): first six integer or pointer arguments in RDI, RSI, RDX, RCX, R8, R9; return value in RAX; RBX, RBP, R12–R15 are callee-saved; all others are caller-saved. Note that RCX is argument register four — and LOOP in 64-bit mode uses RCX as its implicit counter (or ECX for the 32-bit form; the 64-bit LOOP form decrements RCX). Microsoft x64 ABI (Windows): first four arguments in RCX, RDX, R8, R9; 32-byte shadow space must be allocated on the stack by the caller before every CALL; RBX, RBP, RDI, RSI, R12–R15 are callee-saved.
The most common calling-convention bug in assembly retainer work: a subroutine uses EBX as a scratch register inside its body without saving it on entry and restoring it on exit. The callee-save contract is violated silently. No fault occurs inside the subroutine. The symptom appears in the caller: after the CALL and RET sequence, the caller reads EBX expecting its value from before the call, but receives whatever value the subroutine left in EBX. The symptom location (in the caller, at the instruction that uses EBX) is entirely disconnected from the bug location (in the subroutine, which never saves EBX). Diagnosis requires tracing register values at CALL and RET boundaries with a debugger or by adding PUSH/POP probes.
BCD arithmetic instructions are only available in 32-bit mode and were removed from x86_64. DAA (Decimal Adjust after Addition, opcode 0x27): adjusts AL after a binary ADD to produce a valid packed BCD result. DAS (Decimal Adjust after Subtraction, opcode 0x2F): adjusts AL after a binary SUB for packed BCD. AAA (ASCII Adjust after Addition, opcode 0x37): adjusts AL and AH for unpacked BCD addition. AAM (ASCII Adjust after Multiply, opcode 0xD4 0x0A): converts binary result in AL to unpacked BCD in AH:AL. AAD (ASCII Adjust before Division, opcode 0xD5 0x0A): converts unpacked BCD in AH:AL to binary in AL before division. Packed BCD stores two decimal digits per byte (high nibble and low nibble); unpacked BCD stores one decimal digit per byte with 0 in the high nibble. Industrial controllers and legacy sensors often represent measurements as packed BCD, and firmware subroutines that unpack them are a common source of LOOP/ECX conflicts because the ECX register is used both as the iteration counter and as the byte pointer.
Typical assembly retainer work and what it looks like in a work log
LOOP and REP register conflicts are the largest category of assembly retainer work that produces no visible artifact. A subroutine with an ECX conflict has been running correctly for all inputs where the loop body happens not to trigger the corruption — for example, when byte_count is 1, the loop executes once and the ECX corruption does not matter because LOOP only checks ECX after the decrement. The bug manifests only at specific iteration counts with specific data. Work log entry: “unpack_bcd subroutine: INC ECX inside loop body used as data pointer advance; LOOP loop_start decremented ECX after each INC ECX, making pointer stationary; after 2 iterations, ECX = 1 (first byte), not 3 (third byte); 3 wrong BCD digit pairs decoded from first input byte; fix: counter moved to EBX (MOV EBX, byte_count); pointer moved to ESI (MOV ESI, buffer_address; INC ESI); LOOP replaced with DEC EBX; JNZ loop_start; wrong BCD pairs: 3 → 0; 4h.”
Callee-save violations are the second category. A subroutine destroys EBX, ESI, or EDI without restoring them. The symptom appears in the caller’s code at the instruction that reads the destroyed register after the CALL, not inside the subroutine. The subroutine itself may appear to function correctly when tested in isolation because the test harness does not check EBX after the return. Diagnosis requires register-value tracing at CALL and RET boundaries: record the value of EBX (and ESI, EDI, EBP) immediately before the CALL and immediately after the RET, compare them, and identify which subroutine in the call graph modified each register. Work log entry: “compute_checksum subroutine: used EBX as scratch register for intermediate sum without PUSH EBX / POP EBX; caller’s loop counter in EBX corrupted on return; caller loop ran 0x00C3 iterations instead of 0x000A; fix: added PUSH EBX at subroutine entry, POP EBX before RET; wrong iterations: uncontrolled → 0; 3h.”
Stack misalignment is the third category. A subroutine pushes an odd number of DWORD arguments before a CALL, leaving the stack pointer 4 bytes off the required 16-byte alignment for SSE instructions. The misaligned stack does not cause a fault at the PUSH instructions. The fault — a #GP (general protection fault) — occurs inside the called function, at the first SSE instruction that requires 16-byte alignment, with a call stack that points into the called function and gives no indication that the misalignment happened at a PUSH in the calling code. Work log entry: “filter_samples subroutine: pushed 3 DWORD arguments before CALL to apply_fir; stack misaligned by 4 bytes; MOVAPS inside apply_fir raised #GP; fix: added SUB ESP, 4 before argument pushes to pre-align; fault count: uncontrolled → 0; 5h.”
Track assembly developer retainer hours without the status emails
When a 4-hour session diagnoses a LOOP register conflict in unpack_bcd — tracing the interaction between the implicit ECX decrement and the pointer-advance INC ECX, verifying the fix across all hardware status byte formats, and testing with BCD edge cases like 0x99 — the work log must name the subroutine, the conflicting register use, the wrong output count, and the fix. HourTab gives your assembly retainer client a public dashboard URL they can bookmark: hours used, hours remaining, and a work log that names the register and the instruction. No client login. No status emails. CSV in, URL out.
How HourTab tracks assembly developer retainer hours
Assembly retainer work is invisible by the same mechanism that makes the LOOP instruction dangerous: the implicit ECX dependency leaves no syntactic trace. A developer reading a loop body sees INC ECX and sees LOOP loop_start as independent instructions. The conflict between them is only visible when you know that LOOP has an implicit ECX dependency that is not named in the mnemonic. A firmware that has processed hardware status bytes correctly for years may have a latent LOOP register conflict that never fires until a new sensor type returns status bytes in a format that exercises the exact byte_count where the ECX corruption causes the pointer to remain stationary for two iterations.
The work log needs to name the mechanism: which subroutine, which conflicting ECX use inside the loop body, at which iteration count the pointer and counter diverged, what the observable symptom was (wrong data output, premature loop exit, near-infinite loop), and what the fix was. A log entry that says “fixed loop bug, 4h” is not auditable. A log entry that says “unpack_bcd: INC ECX used as pointer advance inside LOOP body; LOOP decremented ECX back after each INC ECX; pointer stationary after iteration 1; all 3 BCD pairs decoded from first byte; fix: counter to EBX, pointer to ESI, LOOP replaced with DEC EBX; JNZ; wrong BCD pairs: 3 → 0; 4h” is auditable.
HourTab gives assembly developers a public retainer-hours URL they send to clients — industrial controller manufacturers maintaining legacy firmware, defense contractors maintaining embedded system software, and performance engineering teams maintaining cryptography or codec inner loops. For assembly retainers, each work log entry should name the mechanism: which subroutine, which register conflict, which calling convention violation, which stack misalignment. Comparative context: assembly retainer work has conceptual overlap with other environments where low-level resource management requires explicit attention — Fortran (another language used in high-performance computation where inner loops are sometimes written in assembly or require register-level awareness); COBOL (similarly long-lived legacy code in industrial and financial systems where maintenance outlasts institutional knowledge); and Ada (used in safety-critical embedded systems alongside assembly, where representation clauses and pragma-controlled memory layout interact with hand-written assembly stubs). Assembly is uniquely positioned as the only language where every implicit register dependency must be understood from the processor manual, not from the language specification.
FAQ: assembly developer retainers
What does an assembly developer on retainer typically do?
An assembly developer on monthly retainer covers LOOP and REP register conflict diagnosis (identifying where implicit ECX use by LOOP, LOOPE, LOOPNE, or REP conflicts with loop body register allocation; fixing by replacing LOOP with explicit DEC EBX; JNZ and moving data pointers to ESI or EDI); callee-save violation diagnosis (tracing register values at CALL and RET boundaries to find subroutines that destroy EBX, ESI, or EDI without restoring them; adding PUSH/POP at subroutine entry/exit); stack alignment maintenance (identifying PUSH sequences that leave the stack misaligned before SSE calls, causing #GP faults inside the called function rather than at the PUSH); BCD instruction maintenance in 32-bit code (DAA, DAS, AAA, AAM, AAD for packed and unpacked BCD decoding in legacy industrial firmware); and calling convention enforcement (cdecl, stdcall, fastcall, Microsoft x64, System V AMD64 ABI).
What assembly language work is most commonly underlogged?
LOOP register conflict diagnosis is the most systematically underlogged assembly retainer work. The LOOP instruction decrements ECX and jumps if ECX is not zero. Its implicit ECX dependency is invisible in the mnemonic. When INC ECX inside the loop body advances a data pointer and LOOP then decrements ECX back, the pointer is stationary across iterations. All data is decoded from the same source byte. No error is raised. Diagnosis requires recognizing the implicit ECX dependency, tracing ECX through each instruction in the loop body, and verifying that no other instruction reads or writes ECX. 3 to 6 hours invisible per occurrence. Callee-save violations: a subroutine destroys EBX without restoring it; the symptom appears in the caller at the instruction that reads EBX after the CALL, not inside the subroutine; 2 to 5 hours. Stack misalignment: the #GP fault is raised inside the called function at the SSE instruction, not at the misaligned PUSH in the calling code; 3 to 6 hours.
What are typical assembly developer retainer rates?
Entry-level assembly developers with 1 to 2 years covering x86 register use, basic MASM or NASM maintenance, and calling convention adherence typically bill at $70 to $130 per hour. Mid-level assembly programmers with 2 to 4 years covering LOOP/REP register conflict diagnosis, callee-save violation tracing, and embedded firmware BCD maintenance typically bill at $110 to $195 per hour. Senior assembly developers with 4 or more years covering x86_64 ABI compliance, SSE/AVX stack alignment, performance-critical inner loop optimization, and legacy DOS or early Windows driver maintenance typically bill at $160 to $290 per hour. Monthly retainer ranges: $1,000 to $2,200 per month for advisory engagements (8 to 15 hours per month); $2,000 to $6,000 per month for active embedded firmware maintenance and inner loop optimization.
What should an assembly developer retainer agreement include?
A retainer agreement should specify: assembler and syntax scope (MASM with Intel syntax for Windows-native work; NASM for cross-platform; GAS with AT&T syntax for Linux kernel and binutils work; operand order is reversed between Intel and AT&T syntax, so the scope must name which toolchain); architecture scope (32-bit IA-32 vs 64-bit x86_64; BCD instructions DAA, DAS, AAA, AAM, AAD are only available in 32-bit mode and are absent from x86_64); calling convention scope (cdecl, stdcall, fastcall for 32-bit; System V AMD64 ABI for Linux/macOS 64-bit; Microsoft x64 ABI for Windows 64-bit; each has different callee-saved registers and argument passing rules); register conflict scope (whether LOOP/REP implicit ECX conflict analysis is in scope; these conflicts affect any subroutine that uses LOOP or REP inside a loop body that also uses ECX for pointer arithmetic, shift counts, or function arguments); and hour logging format (the subroutine name, the conflicting register use, the iteration count at divergence, the symptom, and the fix).
How should assembly developer retainer hours be logged?
Log each assembly retainer session with: the subroutine name where the register conflict was diagnosed (e.g., unpack_bcd); the conflicting ECX use inside the loop body (e.g., INC ECX used as data pointer advance; LOOP also decrements ECX); the iteration count at which the pointer and counter diverged (e.g., after 2 iterations, ECX still pointed to first input byte); the symptom (e.g., 3 wrong BCD digit pairs, all decoded from first input byte); and the fix applied (e.g., counter moved to EBX via MOV EBX, byte_count; pointer moved to ESI via MOV ESI, buffer_address; INC ESI per iteration; LOOP replaced with DEC EBX; JNZ loop_start; wrong BCD pairs: 3 → 0). For callee-save violations: the subroutine name, the destroyed register, the caller instruction that read the wrong value, and the fix. For stack misalignment: the PUSH sequence, the misalignment in bytes, the SSE instruction that faulted, and the corrective SUB ESP.