Control flow: jumps, condiciones y el registro de flags
Los branches son cómo un flujo lineal de instrucciones se vuelve decisión. En C
escribís if (x > limit); en x86-64 la CPU normalmente ve una instrucción que setea
bits de estado, seguida por otra instrucción que salta o cae al próximo bloque según
esos bits. La condición no es un valor en un registro normal. Es dataflow oculto a
través de rflags.
El reset:
cmp a, bno guarda un booleano. Setea flags como si hubiera computadoa - b. El siguiente salto condicional decide si esos flags significan "andá a otro lado".
Cómo funciona realmente
El control flow x86-64 se arma con un vocabulario chico:
| Pieza | Modelo mental | Ejemplos comunes |
|---|---|---|
| label | dirección de instrucción con nombre | LBB0_2: |
jmp |
branch incondicional | siempre setea rip al target |
cmp a, b |
setea flags desde a - b |
compara sin conservar el resultado |
test a, b |
setea flags desde a & b |
chequeo común de cero/null: test rdi, rdi |
jcc label |
branch condicional | je, jne, jl, jle, jg, ja, jb |
El instruction pointer (rip) normalmente avanza a la siguiente instrucción. Un salto
escribe otra próxima dirección en ese control flow. Un jmp incondicional lo hace
siempre. Un salto condicional primero lee condition flags.
Los flags que más vas a leer son:
| Flag | Significado después de aritmética/compare |
|---|---|
ZF |
zero flag: el resultado fue cero |
SF |
sign flag: el bit alto del resultado quedó seteado |
OF |
overflow flag: hubo overflow signed |
CF |
carry flag: hubo carry/borrow unsigned |
Las comparaciones signed y unsigned usan el mismo cmp, pero distintos mnemonics de
salto. Para <= signed, x86 usa jle, que depende de ZF, SF y OF. Para <=
unsigned, usa jbe, que depende de CF y ZF.
Un branch real
El artefacto ejecutable de esta nota vive en
examples/assembly-and-compiler-output/control-flow-jumps-conditions-and-flags/.
static NOINLINE long above_limit(long x, long limit) {
return (x - limit) * 3;
}
static NOINLINE long at_or_below_limit(long x, long limit) {
return (limit - x) * 2;
}
long branch_score(long x, long limit) {
if (x > limit) {
return above_limit(x, limit);
}
return at_or_below_limit(x, limit);
}
En esta máquina, gcc es Apple clang 15 apuntando a Mach-O x86-64. Este es el cuerpo
relevante de la función copiado de la salida real de:
gcc -S -O2 -masm=intel demo.c -o demo.s
_branch_score: ## @branch_score
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
cmp rdi, rsi
jle LBB0_2
## %bb.1:
pop rbp
jmp _above_limit ## TAILCALL
LBB0_2:
pop rbp
jmp _at_or_below_limit ## TAILCALL
.cfi_endproc
Mapeá primero los argumentos: x llega en rdi, limit llega en rsi, y el valor de
retorno sale en rax.
Ahora leé la decisión:
cmp rdi, rsisetea flags como si la CPU hubiera computadordi - rsi, o seax - limit.jle LBB0_2significa "saltá si es menor-o-igual signed". Six <= limit, la ejecución va al segundo camino.- Si el salto no se toma, el control cae en
%bb.1, el caminox > limit. jmp _above_limityjmp _at_or_below_limitson tailcalls. El compilador restaurarbpy salta al helper en vez de llamarlo y volver por un stack frame extra.
Los helpers son intencionalmente chicos, así el branch queda aislado:
_above_limit: ## @above_limit
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
sub rdi, rsi
lea rax, [rdi + 2*rdi]
pop rbp
ret
.cfi_endproc
_at_or_below_limit: ## @at_or_below_limit
.cfi_startproc
## %bb.0:
push rbp
.cfi_def_cfa_offset 16
.cfi_offset rbp, -16
mov rbp, rsp
.cfi_def_cfa_register rbp
sub rsi, rdi
lea rax, [rsi + rsi]
pop rbp
ret
.cfi_endproc
Fijate que el compilador no materializó un booleano de C. No hay un local is_greater
en memoria. La comparación vive en flags durante el tiempo justo para que jle la
consuma.
Los flags son dataflow oculto
Los flags hacen que el assembly sea compacto, pero también hacen fácil perder dependencias. Estas dos instrucciones están conectadas aunque no se pase ningún registro con nombre entre ellas:
cmp rdi, rsi
jle LBB0_2
Si aparece otra instrucción que escribe flags entre ambas, el significado cambia:
cmp rdi, rsi
add rax, rcx ; también actualiza flags
jle LBB0_2 ; ahora lee flags de add, no de cmp
Los compiladores son cuidadosos con esto. Vos, leyendo assembly, también tenés que
serlo. Tratá rflags como un registro real que muchas instrucciones escriben
implícitamente.
Dos patrones aparecen todo el tiempo:
cmp rdi, 0
je was_zero
test rdi, rdi
je was_zero
Ambos pueden implementar un chequeo de cero. test rdi, rdi computa el AND bit a bit
solo para flags, así que si rdi es cero, ZF queda en 1. Es una forma corta e
idiomática para chequeos de null/cero.
Apéndice ARM64
El mismo demo.c se cross-compiló en esta máquina con:
clang -S -O2 -arch arm64 demo.c -o demo.arm64.s
Cuerpo relevante de la función:
_branch_score: ; @branch_score
.cfi_startproc
; %bb.0:
cmp x0, x1
b.le LBB0_2
; %bb.1:
b _above_limit
LBB0_2:
b _at_or_below_limit
.cfi_endproc
ARM64 tiene la misma forma general: compare, branch condicional, fallthrough, branch.
x0 es x, x1 es limit, y cmp x0, x1 setea condition flags ARM desde x0 - x1.
b.le salta en menor-o-igual signed. Los b incondicionales son el equivalente ARM64
de los tailcall jumps de la salida x86-64.
Los helpers muestran otro vocabulario de instrucciones, pero el mismo split de control flow:
_above_limit: ; @above_limit
.cfi_startproc
; %bb.0:
sub x8, x0, x1
add x0, x8, x8, lsl #1
ret
.cfi_endproc
_at_or_below_limit: ; @at_or_below_limit
.cfi_startproc
; %bb.0:
sub x8, x1, x0
lsl x0, x8, #1
ret
.cfi_endproc
La lección cruza arquitecturas: las condiciones normalmente no son objetos en heap, locals o booleanos explícitos. Son estado temporal de máquina, consumido enseguida por branches o conditional selects.
Modos de falla y trade-offs
- Los saltos signed y unsigned son distintos.
jlees signed.jbees unsigned. Los mismos bits pueden producir otro borde de control flow según el tipo de C. - Los flags son estado frágil.
add,sub,cmp,test,and,or,xory muchas otras instrucciones actualizan flags. Un salto lee los flags relevantes más recientes, no la comparación que a vos te parece más cercana. - Fallthrough también es un camino. Si
jleno se toma, la ejecución sigue en la próxima instrucción. Leé siempre el target del salto y el bloque que cae por debajo. - Los tailcalls borran un frame.
jmp _above_limittransfiere control sin pushear una nueva return address. Acá es correcto, pero cambia cómo se ve el stack en un debugger. - Undefined behavior en C le da margen al optimizador. Si una condición depende de comportamiento que el estándar de C no define, el compilador puede remover o reescribir el branch de formas legales pero sorprendentes.
- Branch prediction importa para performance. Un salto condicional puede ser barato si se predice bien y caro si se predice mal. En hot code muchas veces se cambian branches por conditional moves, máscaras o table lookups.
En la práctica
- Leé
cmpcomo resta sin storage.cmp rdi, rsisignifica "setear flags parardi - rsi." - Nombrá la signedness. Decí "
jlemenor-o-igual signed" o "jbebelow-or-equal unsigned" hasta que la diferencia quede pegada. - Trazá primero el fallthrough. Los branches condicionales siempre tienen dos destinos: el label y la próxima instrucción.
- Prestá atención a
test reg, reg. Normalmente significa "¿este registro es cero?" sin modificar el registro. - Conectá branches con tipos fuente. Signedness de C, integer promotions y undefined behavior influyen qué salto puede usar el compilador. Leelo junto con integer promotions e implicit conversions y undefined behavior.
Conecta con: Por qué leer assembly — Compiler Explorer como herramienta diaria · Registros x86-64 y el register file · El core instruction set: mov, aritmética, lea · El stack a nivel assembly · Registros y la ISA
Fuentes
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 1 y Vol. 2 — semántica autoritativa de
cmp,test,jcc,jmp, flags yret. https://www.intel.com/content/www/us/en/developer/articles/technical/intel-sdm.html - Felix Cloutier — x86 and amd64 instruction reference — lookup rápido para saltos condicionales y efectos sobre flags; verificá edge cases contra Intel SDM. https://www.felixcloutier.com/x86/
- System V AMD64 ABI — roles de registros y convenciones call/return usadas para leer la salida del ejemplo. https://gitlab.com/x86-psABIs/x86-64-ABI
- Bryant & O'Hallaron — Computer Systems: A Programmer's Perspective (CS:APP), cap. 3 — condition codes, branches y control flow a nivel máquina. https://csapp.cs.cmu.edu/
- Ed Jorgensen — x86-64 Assembly Language Programming with Ubuntu — ejemplos accesibles de jumps, comparisons y condition codes. https://open.umn.edu/opentextbooks/textbooks/x86-64-assembly-language-programming-with-ubuntu
- Arm Architecture Reference Manual + Apple ARM64 docs — condition flags ARM64, conditional branches y detalles de ABI de plataforma para la salida del apéndice. https://developer.arm.com/documentation/ddi0487/latest · https://developer.apple.com/documentation/xcode/writing-arm64-code-for-apple-platforms