26 de Junio, 2020

Variables globales

Ya comenté en mi última anotación que, trabajando con micros de 8-bits, a veces es importante usar variables globales por temas de rendimiento, pero todo depende en gran medida de qué compilador estemos usando. En mi caso trabajo con SDCC para máquinas con Z80 como CPU.

SDCC no está al nivel de compiladores modernos, como GCC o Clang, y es una de las pocas opciones disponibles porque los compiladores modernos no tienen soporte para este tipo de CPU; y uno de los principales problemas es el número de registros y cómo el compilador decide qué registro usar cuándo.

En C las variables locales usan memoria que se obtiene de la pila cuando llamamos a una función. Vamos a ver un ejemplo sencilo.

#include <stdio.h>
#include <stdint.h>

uint8_t fn()
{
    uint8_t i, a;

    for (i = 0, a = 0; i < 10; ++i)
        if (i & 1)
            a += i;

    return a;
}

Si compilamos este código con SDCC y echamos un vistazo al código intermedio que SDCC genera, tenemos lo siguiente:

_fn::
;test.c:8: for (i = 0, a = 0; i < 10; ++i)
	ld	l, #0x00
	ld	c, #0x00
00105$:
	ld	a, c
	sub	a, #0x0a
	ret	NC
;test.c:9: if (i & 1)
	bit	0, c
	jr	Z,00106$
;test.c:10: a += i;
	add	hl, bc
00106$:
;test.c:8: for (i = 0, a = 0; i < 10; ++i)
	inc	c
;test.c:12: return a;
;test.c:13: }
	jr	00105$

Por defecto SDCC genera ficheros intermedios ASM con el código C en los comentarios, lo cuál es muy útil (aunque hay que saber algo de Z80).

En este caso el resultado es bastante bueno. El compilador ha hecho un buen trabajo usando registros para las variables locales, en lugar de usar la pila: c para el contador del bucle y l para acumular el resultado (además, el valor de retorno va en l). Seguramente no lo hubiéramos escrito así a mano (hubiera sido más eficiente que el contador fuera decreciente para evitar usar el acumulador), pero no está nada mal.

Las cosas se complican mucho cuando la función es más grande o, como en el caso de la semana pasada, se usan punteros. Veamos otro ejemplo.

#include <stdio.h>
#include <stdint.h>

uint8_t fn(uint8_t *p)
{
    uint8_t *b = p;

    while(b[1])
        ++b;

    return *b == *p;
}

Esta función devuelve 1 si en una cadena acabada en 0, el primer elemento es igual al penúltimo. Los detalles tampoco son importantes, pero digamos que "arma" es cierto y "escudo" es falso.

El código que genera SDCC se complica.

_fn::
	push	ix
	ld	ix,#0
	add	ix,sp
	push	af
;test.c:6: uint8_t *b = p;
	ld	c, 4 (ix)
	ld	b, 5 (ix)
	inc	sp
	inc	sp
	push	bc
;test.c:8: while(b[1])
00101$:
	pop	de
	push	de
	inc	de
	ld	a, (de)
	or	a, a
	jr	Z,00103$
;test.c:9: ++b;
	inc	sp
	inc	sp
	push	de
	jr	00101$
00103$:
;test.c:11: return *b == *p;
	pop	hl
	push	hl
	ld	e, (hl)
	ld	a, (bc)
	ld	c, a
	ld	a, e
	sub	a, c
	ld	a, #0x01
	jr	Z,00117$
	xor	a, a
00117$:
	ld	l, a
;test.c:12: }
	ld	sp, ix
	pop	ix
	ret

Hay que controlar un poco más de Z80 para entender completamente la salida del compilador, pero la idea básica son las operaciones que realiza al comienzo de la función para reservar memoria para nuestra variable local. Si usamos Z80count o una herramienta similar, veremos que salen unos 287 ciclos en total.

Supongamos que b es una variable global, y tenemos una función fn_g, el resultado sería:

_fn_g::
;test.c:18: b = p;
	pop	de
	pop	bc
	push	bc
	push	de
	ld	(_b), bc
;test.c:20: while(b[1])
00101$:
	ld	hl, (_b)
	inc	hl
	ld	a, (hl)
	or	a, a
	jr	Z,00103$
;test.c:21: ++b;
	ld	(_b), hl
	jr	00101$
00103$:
;test.c:23: return *b == *p;
	ld	hl, (_b)
	ld	e, (hl)
	ld	a, (bc)
	ld	c, a
	ld	a, e
	sub	a, c
	ld	a, #0x01
	jr	Z,00117$
	xor	a, a
00117$:
	ld	l, a
;test.c:24: }
	ret

Mucho menos código, que suele ser más rápido :D. En este caso son 192 ciclos.

No solo evitamos hacer todas las operaciones con la pila sino que además el compilador no ha usado ix, que es un registro bastante lento en el Z80.

No todo son ventajas. La principal limitación es que nuestro código no será reentrante (no podemos llamar a la función desde dentro de la función), y tampoco podemos suponer que el compilador siempre hará un mal trabajo con variables locales. Además hay casos que usar la pila será buena idea :D.

Aún así, en general creo que se puede asumir que usar la pila va a ser más lento, a no ser que el compilador haga un buen trabajo con el código generado y no use la pila, y en caso de punteros o variables más grandes que un byte, usar variables globales es bastante probable que sea más rápido.

Anotación por Juan J. Martínez, clasificada en: c, programming, gamedev.

Los comentarios están cerrados: los comentarios se cierran automáticamente una vez pasados 30 días. Si quieres comentar algo acerca de la anotación, puedes hacerlo por e-mail.

Algunas anotaciones relacionadas: