Programación en lenguaje ensamblador x86

This translation may be out of date. To help with the translations please access the FreeBSD translations instance.

Marcas registradas

FreeBSD es una marca registrada de la Fundación FreeBSD

Apple, AirPort, FireWire, iMac, iPhone, iPad, Mac, Macintosh, Mac OS, Quicktime, y TrueType son marcas comerciales de Apple Inc., registradas en los Estados Unidos de América y otros países.

Intel, Celeron, Centrino, Core, EtherExpress, i386, i486, Itanium, Pentium, y Xeon son marcas registradas de ntel Corporation o sus subsidiarias en los Estados Unidos de América y otros países.

Linux es una marca registrada de Linus Torvalds.

Microsoft, IntelliMouse, MS-DOS, Outlook, Windows, Windows Media y Windows NT son marcas registradas o marcas comerciales de Microsoft Corporation en los Estados Unidos de América y/o otros países.

UNIX es una marca registrada de The Open Group en los Estados Unidos de América y otros países.

Muchos de los nombres usados por los fabricantes y vendedores para diferenciar sus productos son designados como marcas comerciales. Allí donde estos nombres aparezcan en este documento y el Proyecto FreeBSD fuera consciente de la alegación de marca comercial, los nombres tienen a continuación el símbolo “™” o “®”.

Este artículo fue escrito por G. Adam Stanislav <adam@redprince.net> (2001), y adaptado por Mitchell Horne <mhorne@FreeBSD.org> (2026).

El contenido de este artículo es histórico.

1. Sinopsis

La programación en lenguaje ensamblador bajo UNIX® está muy poco documentada. Por lo general se asume que nadie querría usarla jamás porque los diversos sistemas UNIX se ejecutan en diferentes microprocesadores, de modo que todo debería escribirse en C por portabilidad.

En realidad, la portabilidad de C es todo un mito. Incluso los programas en C necesitan modificarse cuando se portan de un UNIX a otro, independientemente del procesador en que se ejecute cada uno. Habitualmente, un programa así está lleno de sentencias condicionales que dependen del sistema para el que se compila.

Incluso si creyéramos que todo el software de UNIX debería escribirse en C, o en algún otro lenguaje de alto nivel, aun así necesitamos programadores de lenguaje ensamblador: ¿quién más escribiría la sección de la biblioteca de C que accede al kernel?

En este artículo intentaré mostrarle cómo puede usar el lenguaje ensamblador para escribir programas UNIX, específicamente bajo FreeBSD.

Este artículo no explica los fundamentos del lenguaje ensamblador. Hay suficientes recursos sobre eso (para un curso completo en línea de lenguaje ensamblador, consulte el Art of Assembly Language de Randall Hyde; o si prefiere un libro impreso, eche un vistazo a Assembly Language Step-by-Step de Jeff Duntemann (ISBN: 0471375233). Sin embargo, una vez terminado el artículo, cualquier programador de lenguaje ensamblador será capaz de escribir programas para FreeBSD de forma rápida y eficiente.

Copyright ® 2000-2001 G. Adam Stanislav. Todos los derechos reservados.

2. Las herramientas

2.1. El ensamblador

La herramienta más importante para la programación en lenguaje ensamblador es el ensamblador, el software que convierte el código en lenguaje ensamblador a lenguaje máquina. Hay disponibles dos tipos de ensambladores muy diferentes para FreeBSD.

El primero es el as(1) de GNU (devel/binutils), que usa la sintaxis tradicional del lenguaje ensamblador de UNIX. Como alternativa, se puede usar clang(1), que es un reemplazo compatible del ensamblador de GNU y viene con el sistema.

El otro es nasm(1) (devel/nasm). Este ensamblador usa la sintaxis de Intel, y su principal ventaja es que puede ensamblar código para muchos sistemas operativos.

Este artículo usa la sintaxis de nasm porque la mayoría de los programadores de lenguaje ensamblador que llegan a FreeBSD desde otros sistemas operativos la encontrarán más fácil de entender. Y, además, porque, francamente, es a lo que estoy acostumbrado.

2.2. El enlazador

La salida del ensamblador, como la de cualquier compilador, necesita enlazarse para formar un archivo ejecutable.

El enlazador estándar ld(1) viene con FreeBSD. Funciona con el código ensamblado con cualquiera de los dos ensambladores.

3. Llamadas al sistema

3.1. Convención de llamada por defecto

Por defecto, el kernel de FreeBSD usa la convención de llamada de C. Además, aunque se accede al kernel usando int 80h, se asume que el programa llamará a una función que emite int 80h, en lugar de emitir int 80h directamente.

Esta convención es muy cómoda, y bastante superior a la convención de Microsoft® usada por MS-DOS®. ¿Por qué? Porque la convención de UNIX permite que cualquier programa escrito en cualquier lenguaje acceda al kernel.

Un programa en lenguaje ensamblador también puede hacerlo. Por ejemplo, podríamos abrir un archivo:

kernel:
	int	80h	; Call kernel
	ret

open:
	push	dword mode
	push	dword flags
	push	dword path
	mov	eax, 5
	call	kernel
	add	esp, byte 12
	ret

Esta es una forma de programar muy limpia y portable. Si necesita portar el código a un sistema UNIX que use una interrupción diferente, o una forma diferente de pasar los parámetros, todo lo que necesita cambiar es el procedimiento kernel.

Pero a los programadores de lenguaje ensamblador les gusta recortar ciclos. El ejemplo anterior requiere una combinación call/ret. Podemos eliminarla haciendo push de un dword adicional:

open:
	push	dword mode
	push	dword flags
	push	dword path
	mov	eax, 5
	push	eax		; Or any other dword
	int	80h
	add	esp, byte 16

El 5 que hemos colocado en EAX identifica la función del kernel, en este caso open.

3.2. Convención de llamada alternativa

FreeBSD es un sistema extremadamente flexible. Ofrece otras formas de llamar al kernel. Sin embargo, para que funcione, el sistema debe tener instalada la emulación de Linux®.

Linux es un sistema similar a UNIX. Sin embargo, su kernel usa la misma convención de llamadas al sistema de pasar parámetros en registros que usa MS-DOS. Al igual que con la convención de UNIX, el número de función se coloca en EAX. Los parámetros, sin embargo, no se pasan en la pila sino en EBX, ECX, EDX, ESI, EDI, EBP:

open:
	mov	eax, 5
	mov	ebx, path
	mov	ecx, flags
	mov	edx, mode
	int	80h

Esta convención tiene una gran desventaja frente a la forma de UNIX®, al menos en lo que respecta a la programación en lenguaje ensamblador: Cada vez que hace una llamada al kernel debe hacer push de los registros, y luego pop de ellos más tarde. Esto hace que su código sea más voluminoso y más lento. No obstante, FreeBSD le ofrece una opción.

Si elige la convención de Linux, debe informar de ello al sistema. Después de ensamblar y enlazar su programa, necesita marcar el ejecutable:

% brandelf -t Linux filename

3.3. ¿Qué convención debería usar?

Si está programando específicamente para FreeBSD, siempre debería usar la convención de UNIX: Es más rápida, puede almacenar variables globales en registros, no tiene que marcar el ejecutable, y no impone la instalación del paquete de emulación de Linux en el sistema de destino.

Si quiere crear código portable que también pueda ejecutarse en Linux, probablemente aun así querrá dar a los usuarios de FreeBSD un código tan eficiente como sea posible. Le mostraré cómo puede lograrlo después de haber explicado los fundamentos.

3.4. Números de llamada

Para indicarle al kernel qué servicio del sistema está llamando, coloque su número en EAX. Por supuesto, necesita saber cuál es el número.

3.4.1. El archivo syscalls

Los números están listados en syscalls. locate syscalls encuentra este archivo en varios formatos diferentes, todos producidos automáticamente a partir de syscalls.master.

Puede encontrar el archivo maestro para la convención de llamada por defecto de UNIX en /usr/src/sys/kern/syscalls.master. Si necesita usar la otra convención implementada en el modo de emulación de Linux, lea /usr/src/sys/i386/linux/syscalls.master.

FreeBSD y Linux no solo usan convenciones de llamada diferentes, sino que a veces usan números diferentes para las mismas funciones.

syscalls.master describe cómo se debe hacer la llamada:

0	STD	NOHIDE	{ int nosys(void); } syscall nosys_args int
1	STD	NOHIDE	{ void exit(int rval); } exit rexit_args void
2	STD	POSIX	{ int fork(void); }
3	STD	POSIX	{ ssize_t read(int fd, void *buf, size_t nbyte); }
4	STD	POSIX	{ ssize_t write(int fd, const void *buf, size_t nbyte); }
5	STD	POSIX	{ int open(char *path, int flags, int mode); }
6	STD	POSIX	{ int close(int fd); }
etc...

Es la columna de más a la izquierda la que nos indica el número a colocar en EAX.

La columna de más a la derecha nos indica qué parámetros hacer push. Se hace push de ellos de derecha a izquierda.

Por ejemplo, para hacer open de un archivo, necesitamos hacer push primero de mode, luego de flags, y luego de la dirección en la que está almacenado path.

4. Valores de retorno

Una llamada al sistema no sería útil la mayor parte del tiempo si no devolviera algún tipo de valor: El descriptor de archivo de un archivo abierto, el número de bytes leídos a un búfer, la hora del sistema, etc.

Además, el sistema necesita informarnos si ocurre un error: Un archivo no existe, los recursos del sistema están agotados, pasamos un parámetro inválido, etc.

4.1. Páginas de manual

El lugar tradicional para buscar información sobre las diversas llamadas al sistema en sistemas UNIX son las páginas de manual. FreeBSD describe sus llamadas al sistema en la sección 2, a veces en la sección 3.

Por ejemplo, open(2) dice:

Si tiene éxito, open() devuelve un entero no negativo, denominado descriptor de archivo. Devuelve -1 en caso de fallo, y establece errno para indicar el error.

El programador de lenguaje ensamblador nuevo en UNIX y FreeBSD se hará de inmediato la desconcertante pregunta: ¿dónde está errno y cómo llego a él?

La información presentada en las páginas de manual se aplica a los programas en C. El programador de lenguaje ensamblador necesita información adicional.

4.2. ¿Dónde están los valores de retorno?

Desafortunadamente, depende…​ Para la mayoría de las llamadas al sistema está en EAX, pero no para todas. Una buena regla general, cuando se trabaja con una llamada al sistema por primera vez, es buscar el valor de retorno en EAX. Si no está ahí, necesita investigar más.

Conozco una llamada al sistema que devuelve el valor en EDX: SYS_fork. Todas las demás con las que he trabajado usan EAX. Pero todavía no he trabajado con todas.

Si no puede encontrar la respuesta aquí ni en ningún otro sitio, estudie el código fuente de libc y vea cómo se comunica con el kernel.

4.3. ¿Dónde está errno?

En realidad, en ninguna parte…​

errno es parte del lenguaje C, no del kernel de UNIX. Al acceder a los servicios del kernel directamente, el código de error se devuelve en EAX, el mismo registro en el que generalmente acaba el valor de retorno propiamente dicho.

Esto tiene todo el sentido. Si no hay error, no hay código de error. Si hay un error, no hay valor de retorno. Un mismo registro puede contener cualquiera de los dos.

4.4. Determinar que ha ocurrido un error

Al usar la convención de llamada estándar de FreeBSD, el carry flag se limpia en caso de éxito, y se establece en caso de fallo.

Al usar el modo de emulación de Linux, el valor con signo en EAX es no negativo en caso de éxito, y contiene el valor de retorno. En caso de error, el valor es negativo, es decir, -errno.

5. Crear código portable

La portabilidad no suele ser uno de los puntos fuertes del lenguaje ensamblador. Sin embargo, escribir programas en lenguaje ensamblador para diferentes plataformas es posible, especialmente con nasm. He escrito bibliotecas en lenguaje ensamblador que pueden ensamblarse para sistemas operativos tan diferentes como Windows® y FreeBSD.

Es aún más posible cuando quiere que su código se ejecute en dos plataformas que, aunque diferentes, se basan en arquitecturas similares.

Por ejemplo, FreeBSD es UNIX, Linux es similar a UNIX. Solo he mencionado tres diferencias entre ellos (desde la perspectiva de un programador de lenguaje ensamblador): La convención de llamada, los números de función, y la forma de devolver valores.

5.1. Lidiar con los números de función

En muchos casos los números de función son los mismos. Sin embargo, incluso cuando no lo son, el problema es fácil de abordar: En lugar de usar números en su código, use constantes que haya declarado de forma diferente según la arquitectura de destino:

%ifdef	LINUX
%define	SYS_execve	11
%else
%define	SYS_execve	59
%endif

5.2. Lidiar con las convenciones

Tanto la convención de llamada como el valor de retorno (el problema de errno) pueden resolverse con macros:

%ifdef	LINUX

%macro	system	0
	call	kernel
%endmacro

align 4
kernel:
	push	ebx
	push	ecx
	push	edx
	push	esi
	push	edi
	push	ebp

	mov	ebx, [esp+32]
	mov	ecx, [esp+36]
	mov	edx, [esp+40]
	mov	esi, [esp+44]
	mov	ebp, [esp+48]
	int	80h

	pop	ebp
	pop	edi
	pop	esi
	pop	edx
	pop	ecx
	pop	ebx

	or	eax, eax
	js	.errno
	clc
	ret

.errno:
	neg	eax
	stc
	ret

%else

%macro	system	0
	int	80h
%endmacro

%endif

5.3. Lidiar con otros problemas de portabilidad

Las soluciones anteriores pueden manejar la mayoría de los casos de escritura de código portable entre FreeBSD y Linux. No obstante, con algunos servicios del kernel las diferencias son más profundas.

En ese caso, necesita escribir dos manejadores diferentes para esas llamadas al sistema concretas, y usar ensamblado condicional. Afortunadamente, la mayor parte de su código hace algo distinto de llamar al kernel, así que normalmente solo necesitará unas pocas de estas secciones condicionales en su código.

5.4. Usar una biblioteca

Puede evitar por completo los problemas de portabilidad en su código principal escribiendo una biblioteca de llamadas al sistema. Cree una biblioteca separada para FreeBSD, otra diferente para Linux, y aún otras bibliotecas para más sistemas operativos.

En su biblioteca, escriba una función separada (o procedimiento, si prefiere la terminología tradicional del lenguaje ensamblador) para cada llamada al sistema. Use la convención de C de pasar parámetros. Pero siga usando EAX para pasar el número de llamada. En ese caso, su biblioteca de FreeBSD puede ser muy simple, ya que muchas funciones aparentemente diferentes pueden ser simplemente etiquetas del mismo código:

sys.open:
sys.close:
[etc...]
	int	80h
	ret

Su biblioteca de Linux requerirá más funciones diferentes. Pero incluso aquí puede agrupar las llamadas al sistema que usan el mismo número de parámetros:

sys.exit:
sys.close:
[etc... one-parameter functions]
	push	ebx
	mov	ebx, [esp+12]
	int	80h
	pop	ebx
	jmp	sys.return

...

sys.return:
	or	eax, eax
	js	sys.err
	clc
	ret

sys.err:
	neg	eax
	stc
	ret

El enfoque de la biblioteca puede parecer inconveniente al principio porque le obliga a producir un archivo separado del que depende su código. Pero tiene muchas ventajas: por un lado, solo necesita escribirla una vez y puede usarla para todos sus programas. Incluso puede permitir que otros programadores de lenguaje ensamblador la usen, o quizá usar una escrita por otra persona. Pero quizá la mayor ventaja de la biblioteca es que su código puede portarse a otros sistemas, incluso por otros programadores, simplemente escribiendo una nueva biblioteca sin ningún cambio en su código.

Si no le gusta la idea de tener una biblioteca, puede al menos colocar todas sus llamadas al sistema en un archivo de lenguaje ensamblador separado y enlazarlo con su programa principal. Aquí, de nuevo, todo lo que los portadores tienen que hacer es crear un nuevo archivo objeto para enlazar con su programa principal.

5.5. Usar un archivo de inclusión

Si publica su software como (o con) código fuente, puede usar macros y colocarlas en un archivo separado, que incluye en su código.

Los portadores de su software simplemente escribirán un nuevo archivo de inclusión. No se necesita ninguna biblioteca ni archivo objeto externo, y aun así su código es portable sin ninguna necesidad de editarlo.

Este es el enfoque que usaremos a lo largo de este artículo. Llamaremos a nuestro archivo de inclusión system.inc, y le añadiremos contenido cada vez que tratemos una nueva llamada al sistema.

Podemos empezar nuestro system.inc declarando los descriptores de archivo estándar:

%define	stdin	0
%define	stdout	1
%define	stderr	2

A continuación, creamos un nombre simbólico para cada llamada al sistema:

%define	SYS_nosys	0
%define	SYS_exit	1
%define	SYS_fork	2
%define	SYS_read	3
%define	SYS_write	4
; [etc...]

Añadimos un procedimiento corto, no global, con un nombre largo, para no reutilizar accidentalmente el nombre en nuestro código:

section	.text
align 4
access.the.bsd.kernel:
	int	80h
	ret

Creamos una macro que toma un argumento, el número de syscall:

%macro	system	1
	mov	eax, %1
	call	access.the.bsd.kernel
%endmacro

Finalmente, creamos macros para cada syscall. Estas macros no toman argumentos.

%macro	sys.exit	0
	system	SYS_exit
%endmacro

%macro	sys.fork	0
	system	SYS_fork
%endmacro

%macro	sys.read	0
	system	SYS_read
%endmacro

%macro	sys.write	0
	system	SYS_write
%endmacro

; [etc...]

Adelante, introdúzcalo en su editor y guárdelo como system.inc. Le añadiremos más a medida que tratemos más syscalls.

6. Nuestro primer programa

Ya estamos listos para nuestro primer programa, el obligatorio ¡Hola, mundo!

	%include	'system.inc'

	section	.data
	hello	db	'Hello, World!', 0Ah
	hbytes	equ	$-hello

	section	.text
	global	_start
_start:
	push	dword hbytes
	push	dword hello
	push	dword stdout
	sys.write

	push	dword 0
	sys.exit

Esto es lo que hace: la línea 1 incluye las definiciones, las macros, y el código de system.inc.

Las líneas 3-5 son los datos: la línea 3 inicia la sección/segmento de datos. La línea 4 contiene la cadena "Hello, World!" seguida de una nueva línea (0Ah). La línea 5 crea una constante que contiene la longitud en bytes de la cadena de la línea 4.

Las líneas 7-16 contienen el código. Observe que FreeBSD usa el formato de archivo elf para sus ejecutables, que requiere que todo programa comience en el punto etiquetado como _start (o, más precisamente, el enlazador así lo espera). Esta etiqueta tiene que ser global.

Las líneas 10-13 piden al sistema que escriba hbytes bytes de la cadena hello en stdout.

Las líneas 15-16 piden al sistema que finalice el programa con el valor de retorno 0. La syscall SYS_exit nunca retorna, así que el código termina ahí.

Si ha llegado a UNIX desde un entorno de lenguaje ensamblador de MS-DOS, puede estar acostumbrado a escribir directamente en el hardware de vídeo. Nunca tendrá que preocuparse por esto en FreeBSD, ni en ninguna otra variante de UNIX. En lo que a usted respecta, está escribiendo en un archivo conocido como stdout. Este puede ser la pantalla de vídeo, o un terminal telnet, o un archivo real, o incluso la entrada de otro programa. Cuál de ellos sea, es algo que le corresponde averiguar al sistema.

6.1. Ensamblar el código

Escriba el código en un editor, y guárdelo en un archivo llamado hello.asm. Necesita devel/nasm para ensamblarlo.

Ahora puede ensamblar, enlazar, y ejecutar el código:

% nasm -f elf hello.asm
% ld -s -o hello hello.o
% ./hello
Hello, World!
%

7. Escribir filtros UNIX

Un tipo común de aplicación UNIX es un filtro: un programa que lee datos de stdin, los procesa de algún modo, y luego escribe el resultado en stdout.

En este capítulo, desarrollaremos un filtro sencillo, y aprenderemos cómo leer de stdin y escribir en stdout. Este filtro convertirá cada byte de su entrada en un número hexadecimal seguido de un espacio en blanco.

%include	'system.inc'

section	.data
hex	db	'0123456789ABCDEF'
buffer	db	0, 0, ' '

section	.text
global	_start
_start:
	; read a byte from stdin
	push	dword 1
	push	dword buffer
	push	dword stdin
	sys.read
	add	esp, byte 12
	or	eax, eax
	je	.done

	; convert it to hex
	movzx	eax, byte [buffer]
	mov	edx, eax
	shr	dl, 4
	mov	dl, [hex+edx]
	mov	[buffer], dl
	and	al, 0Fh
	mov	al, [hex+eax]
	mov	[buffer+1], al

	; print it
	push	dword 3
	push	dword buffer
	push	dword stdout
	sys.write
	add	esp, byte 12
	jmp	short _start

.done:
	push	dword 0
	sys.exit

En la sección de datos creamos un array llamado hex. Contiene los 16 dígitos hexadecimales en orden ascendente. Al array le sigue un búfer que usaremos tanto para la entrada como para la salida. Los dos primeros bytes del búfer se establecen inicialmente a 0. Aquí es donde escribiremos los dos dígitos hexadecimales (el primer byte es también donde leeremos la entrada). El tercer byte es un espacio.

La sección de código consta de cuatro partes: leer el byte, convertirlo a un número hexadecimal, escribir el resultado, y finalmente salir del programa.

Para leer el byte, pedimos al sistema que lea un byte de stdin, y lo almacene en el primer byte del buffer. El sistema devuelve el número de bytes leídos en EAX. Este será 1 mientras lleguen datos, o 0, cuando no haya más datos de entrada disponibles. Por lo tanto, comprobamos el valor de EAX. Si es 0, saltamos a .done, de lo contrario continuamos.

En aras de la simplicidad, por ahora ignoramos la posibilidad de una condición de error.

La conversión hexadecimal lee el byte del buffer a EAX, o en realidad solo a AL, limpiando a ceros los bits restantes de EAX. También copiamos el byte a EDX porque necesitamos convertir los cuatro bits superiores (nibble) por separado de los cuatro bits inferiores. Almacenamos el resultado en los dos primeros bytes del búfer.

A continuación, pedimos al sistema que escriba los tres bytes del búfer, es decir, los dos dígitos hexadecimales y el espacio en blanco, en stdout. Luego saltamos de vuelta al principio del programa y procesamos el siguiente byte.

Una vez que no queda más entrada, pedimos al sistema que salga de nuestro programa, devolviendo un cero, que es el valor tradicional que significa que el programa tuvo éxito.

Adelante, guarde el código en un archivo llamado hex.asm, luego escriba lo siguiente (el ^D significa pulsar la tecla control y teclear D mientras mantiene pulsada la tecla control):

% nasm -f elf hex.asm
% ld -s -o hex hex.o
% ./hex
Hello, World!
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A Here I come!
48 65 72 65 20 49 20 63 6F 6D 65 21 0A ^D %

Si está migrando a UNIX desde MS-DOS, puede que se pregunte por qué cada línea termina con 0A en lugar de 0D 0A. Esto se debe a que UNIX no usa la convención cr/lf, sino una convención de "nueva línea", que es 0A en hexadecimal.

¿Podemos mejorar esto? Bueno, por un lado, es un poco confuso porque una vez que hemos convertido una línea de texto, nuestra entrada ya no comienza al principio de la línea. Podemos modificarlo para que imprima una nueva línea en lugar de un espacio después de cada 0A:

%include	'system.inc'

section	.data
hex	db	'0123456789ABCDEF'
buffer	db	0, 0, ' '

section	.text
global	_start
_start:
	mov	cl, ' '

.loop:
	; read a byte from stdin
	push	dword 1
	push	dword buffer
	push	dword stdin
	sys.read
	add	esp, byte 12
	or	eax, eax
	je	.done

	; convert it to hex
	movzx	eax, byte [buffer]
	mov	[buffer+2], cl
	cmp	al, 0Ah
	jne	.hex
	mov	[buffer+2], al

.hex:
	mov	edx, eax
	shr	dl, 4
	mov	dl, [hex+edx]
	mov	[buffer], dl
	and	al, 0Fh
	mov	al, [hex+eax]
	mov	[buffer+1], al

	; print it
	push	dword 3
	push	dword buffer
	push	dword stdout
	sys.write
	add	esp, byte 12
	jmp	short .loop

.done:
	push	dword 0
	sys.exit

Hemos almacenado el espacio en el registro CL. Podemos hacer esto de forma segura porque, a diferencia de Microsoft Windows, las llamadas al sistema de UNIX no modifican el valor de ningún registro que no usen para devolver un valor.

Eso significa que solo necesitamos establecer CL una vez. Por lo tanto, hemos añadido una nueva etiqueta .loop y saltamos a ella para el siguiente byte en lugar de saltar a _start. También hemos añadido la etiqueta .hex para poder tener un espacio en blanco o una nueva línea como tercer byte del buffer.

Una vez que haya cambiado hex.asm para reflejar estos cambios, escriba:

% nasm -f elf hex.asm
% ld -s -o hex hex.o
% ./hex
Hello, World!
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A
Here I come!
48 65 72 65 20 49 20 63 6F 6D 65 21 0A
^D %

Eso se ve mejor. ¡Pero este código es bastante ineficiente! Estamos haciendo una llamada al sistema por cada byte individual dos veces (una para leerlo, otra para escribir la salida).

8. Entrada y salida con búfer

Podemos mejorar la eficiencia de nuestro código usando búferes para nuestra entrada y salida. Creamos un búfer de entrada y leemos toda una secuencia de bytes de una sola vez. Luego los obtenemos uno por uno del búfer.

También creamos un búfer de salida. Almacenamos nuestra salida en él hasta que se llena. En ese momento pedimos al kernel que escriba el contenido del búfer en stdout.

El programa termina cuando no hay más entrada. Pero aún necesitamos pedir al kernel que escriba el contenido de nuestro búfer de salida en stdout una última vez, de lo contrario parte de nuestra salida llegaría al búfer de salida, pero nunca se enviaría. No lo olvide, o se estará preguntando por qué falta parte de su salida.

%include	'system.inc'

%define	BUFSIZE	2048

section	.data
hex	db	'0123456789ABCDEF'

section .bss
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE

section	.text
global	_start
_start:
	sub	eax, eax
	sub	ebx, ebx
	sub	ecx, ecx
	mov	edi, obuffer

.loop:
	; read a byte from stdin
	call	getchar

	; convert it to hex
	mov	dl, al
	shr	al, 4
	mov	al, [hex+eax]
	call	putchar

	mov	al, dl
	and	al, 0Fh
	mov	al, [hex+eax]
	call	putchar

	mov	al, ' '
	cmp	dl, 0Ah
	jne	.put
	mov	al, dl

.put:
	call	putchar
	jmp	short .loop

align 4
getchar:
	or	ebx, ebx
	jne	.fetch

	call	read

.fetch:
	lodsb
	dec	ebx
	ret

read:
	push	dword BUFSIZE
	mov	esi, ibuffer
	push	esi
	push	dword stdin
	sys.read
	add	esp, byte 12
	mov	ebx, eax
	or	eax, eax
	je	.done
	sub	eax, eax
	ret

align 4
.done:
	call	write		; flush output buffer
	push	dword 0
	sys.exit

align 4
putchar:
	stosb
	inc	ecx
	cmp	ecx, BUFSIZE
	je	write
	ret

align 4
write:
	sub	edi, ecx	; start of buffer
	push	ecx
	push	edi
	push	dword stdout
	sys.write
	add	esp, byte 12
	sub	eax, eax
	sub	ecx, ecx	; buffer is empty now
	ret

Ahora tenemos una tercera sección en el código fuente, llamada .bss. Esta sección no se incluye en nuestro archivo ejecutable, y, por lo tanto, no puede inicializarse. Usamos resb en lugar de db. Simplemente reserva el tamaño solicitado de memoria sin inicializar para nuestro uso.

Aprovechamos el hecho de que el sistema no modifica los registros: Usamos registros para lo que, de otro modo, tendría que ser variables globales almacenadas en la sección .data. Esta es también la razón por la que la convención de UNIX de pasar parámetros a las llamadas al sistema en la pila es superior a la convención de Microsoft de pasarlos en los registros: podemos conservar los registros para nuestro propio uso.

Usamos EDI y ESI como punteros al siguiente byte que se va a leer o escribir. Usamos EBX y ECX para llevar la cuenta del número de bytes en los dos búferes, para saber cuándo volcar la salida al sistema, o leer más entrada de él.

Veamos cómo funciona ahora:

% nasm -f elf hex.asm
% ld -s -o hex hex.o
% ./hex
Hello, World!
Here I come!
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A
48 65 72 65 20 49 20 63 6F 6D 65 21 0A
^D %

¿No es lo que esperaba? El programa no imprimió la salida hasta que pulsamos ^D. Eso es fácil de arreglar insertando tres líneas de código para escribir la salida cada vez que hemos convertido una nueva línea a 0A. He marcado las tres líneas con > (no copie el > en su hex.asm).

%include	'system.inc'

%define	BUFSIZE	2048

section	.data
hex	db	'0123456789ABCDEF'

section .bss
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE

section	.text
global	_start
_start:
	sub	eax, eax
	sub	ebx, ebx
	sub	ecx, ecx
	mov	edi, obuffer

.loop:
	; read a byte from stdin
	call	getchar

	; convert it to hex
	mov	dl, al
	shr	al, 4
	mov	al, [hex+eax]
	call	putchar

	mov	al, dl
	and	al, 0Fh
	mov	al, [hex+eax]
	call	putchar

	mov	al, ' '
	cmp	dl, 0Ah
	jne	.put
	mov	al, dl

.put:
	call	putchar
>	cmp	al, 0Ah
>	jne	.loop
>	call	write
	jmp	short .loop

align 4
getchar:
	or	ebx, ebx
	jne	.fetch

	call	read

.fetch:
	lodsb
	dec	ebx
	ret

read:
	push	dword BUFSIZE
	mov	esi, ibuffer
	push	esi
	push	dword stdin
	sys.read
	add	esp, byte 12
	mov	ebx, eax
	or	eax, eax
	je	.done
	sub	eax, eax
	ret

align 4
.done:
	call	write		; flush output buffer
	push	dword 0
	sys.exit

align 4
putchar:
	stosb
	inc	ecx
	cmp	ecx, BUFSIZE
	je	write
	ret

align 4
write:
	sub	edi, ecx	; start of buffer
	push	ecx
	push	edi
	push	dword stdout
	sys.write
	add	esp, byte 12
	sub	eax, eax
	sub	ecx, ecx	; buffer is empty now
	ret

Ahora, veamos cómo funciona:

% nasm -f elf hex.asm
% ld -s -o hex hex.o
% ./hex
Hello, World!
48 65 6C 6C 6F 2C 20 57 6F 72 6C 64 21 0A
Here I come!
48 65 72 65 20 49 20 63 6F 6D 65 21 0A
^D %

No está mal para un ejecutable de 644 bytes, ¿verdad?

Este enfoque de entrada/salida con búfer todavía contiene un peligro oculto. Lo comentaré -y lo arreglaré- más adelante, cuando hable sobre el lado oscuro del uso de búferes.

8.1. Cómo devolver un carácter a la entrada

Este puede ser un tema algo avanzado, de interés principalmente para programadores familiarizados con la teoría de los compiladores. Si lo desea, puede saltar a la siguiente sección, y quizá leer esto más tarde.

Aunque nuestro programa de ejemplo no lo requiere, los filtros más sofisticados a menudo necesitan mirar hacia adelante. En otras palabras, pueden necesitar ver cuál es el siguiente carácter (o incluso varios caracteres). Si el siguiente carácter tiene un cierto valor, es parte del token que se está procesando actualmente. De lo contrario, no lo es.

Por ejemplo, puede estar analizando el flujo de entrada en busca de una cadena de texto (p. ej., al implementar un compilador de un lenguaje): Si a un carácter le sigue otro carácter, o quizá un dígito, es parte del token que está procesando. Si le sigue un espacio en blanco, o algún otro valor, entonces no es parte del token actual.

Esto presenta un problema interesante: ¿cómo devolver el siguiente carácter al flujo de entrada, de modo que pueda leerse de nuevo más tarde?

Una posible solución es almacenarlo en una variable de carácter, y luego establecer un indicador. Podemos modificar getchar para que compruebe el indicador, y si está establecido, obtenga el byte de esa variable en lugar del búfer de entrada, y restablezca el indicador. Pero, por supuesto, eso nos ralentiza.

El lenguaje C tiene una función ungetc(), precisamente para ese propósito. ¿Hay una forma rápida de implementarla en nuestro código? Me gustaría que volviera hacia arriba y echara un vistazo al procedimiento getchar y viera si puede encontrar una solución buena y rápida antes de leer el siguiente párrafo. Luego vuelva aquí y vea mi propia solución.

La clave para devolver un carácter al flujo está en cómo obtenemos los caracteres en primer lugar:

Primero comprobamos si el búfer está vacío evaluando el valor de EBX. Si es cero, llamamos al procedimiento read.

Si tenemos un carácter disponible, usamos lodsb, y luego decrementamos el valor de EBX. La instrucción lodsb es efectivamente idéntica a:

mov	al, [esi]
	inc	esi

El byte que hemos obtenido permanece en el búfer hasta la próxima vez que se llame a read. No sabemos cuándo ocurre eso, pero sí sabemos que no ocurrirá hasta la siguiente llamada a getchar. Por lo tanto, para "devolver" el último byte leído al flujo, todo lo que tenemos que hacer es decrementar el valor de ESI e incrementar el valor de EBX:

ungetc:
	dec	esi
	inc	ebx
	ret

¡Pero tenga cuidado! Hacer esto es perfectamente seguro si nuestra anticipación es de como mucho un carácter cada vez. Si estamos examinando más de un carácter próximo y llamamos a ungetc varias veces seguidas, funcionará la mayoría de las veces, pero no siempre (y será difícil de depurar). ¿Por qué?

Porque mientras getchar no tenga que llamar a read, todos los bytes leídos por anticipado siguen en el búfer, y nuestro ungetc funciona sin ningún fallo. Pero en el momento en que getchar llama a read, el contenido del búfer cambia.

Siempre podemos confiar en que ungetc funcione correctamente sobre el último carácter que hemos leído con getchar, pero no sobre nada que hayamos leído antes de eso.

Si su programa lee más de un byte por anticipado, tiene al menos dos opciones:

Si es posible, modifique el programa para que solo lea un byte por anticipado. Esta es la solución más sencilla.

Si esa opción no está disponible, determine en primer lugar el número máximo de caracteres que su programa necesita devolver al flujo de entrada de una sola vez. Aumente ese número ligeramente, solo para estar seguro, preferiblemente a un múltiplo de 16, para que se alinee bien. Luego modifique la sección .bss de su código, y cree un pequeño búfer "de reserva" justo antes de su búfer de entrada, algo así:

section	.bss
	resb	16	; or whatever the value you came up with
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE

También necesita modificar su ungetc para pasar en AL el valor del byte que se va a devolver:

ungetc:
	dec	esi
	inc	ebx
	mov	[esi], al
	ret

Con esta modificación, puede llamar a ungetc hasta 17 veces seguidas de forma segura (la primera llamada seguirá estando dentro del búfer, las 16 restantes pueden estar dentro del búfer o dentro del de "reserva").

9. Argumentos de línea de comandos

Nuestro programa hex será más útil si puede leer los nombres de un archivo de entrada y de salida desde su línea de comandos, es decir, si puede procesar los argumentos de línea de comandos. Pero…​ ¿dónde están?

Antes de que un sistema UNIX inicie un programa, hace push de algunos datos en la pila, y luego salta a la etiqueta _start del programa. Sí, he dicho salta, no llama. Eso significa que se puede acceder a los datos leyendo [esp+offset], o simplemente haciendo pop de ellos.

El valor en la cima de la pila contiene el número de argumentos de línea de comandos. Tradicionalmente se llama argc, por "argument count" (recuento de argumentos).

A continuación siguen los argumentos de línea de comandos, los argc que haya. Normalmente se los denomina argv, por "argument value(s)" (valor(es) de argumento). Es decir, obtenemos argv[0], argv[1], …​, argv[argc-1]. Estos no son los argumentos en sí, sino punteros a los argumentos, es decir, direcciones de memoria de los argumentos reales. Los argumentos en sí son cadenas de caracteres terminadas en NUL.

A la lista argv le sigue un puntero NULL, que es simplemente un 0. Hay más, pero esto es suficiente para nuestros propósitos por ahora.

Si viene del entorno de programación de MS-DOS, la principal diferencia es que cada argumento está en una cadena separada. La segunda diferencia es que no hay un límite práctico sobre cuántos argumentos puede haber.

Armados con este conocimiento, estamos casi listos para la siguiente versión de hex.asm. Primero, sin embargo, necesitamos añadir unas pocas líneas a system.inc:

Primero, necesitamos añadir dos nuevas entradas a nuestra lista de números de llamada al sistema:

%define	SYS_open	5
%define	SYS_close	6

Luego añadimos dos nuevas macros al final del archivo:

%macro	sys.open	0
	system	SYS_open
%endmacro

%macro	sys.close	0
	system	SYS_close
%endmacro

Aquí está, entonces, nuestro código fuente modificado:

%include	'system.inc'

%define	BUFSIZE	2048

section	.data
fd.in	dd	stdin
fd.out	dd	stdout
hex	db	'0123456789ABCDEF'

section .bss
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE

section	.text
align 4
err:
	push	dword 1		; return failure
	sys.exit

align 4
global	_start
_start:
	add	esp, byte 8	; discard argc and argv[0]

	pop	ecx
	jecxz	.init		; no more arguments

	; ECX contains the path to input file
	push	dword 0		; O_RDONLY
	push	ecx
	sys.open
	jc	err		; open failed

	add	esp, byte 8
	mov	[fd.in], eax

	pop	ecx
	jecxz	.init		; no more arguments

	; ECX contains the path to output file
	push	dword 420	; file mode (644 octal)
	push	dword 0200h | 0400h | 01h
	; O_CREAT | O_TRUNC | O_WRONLY
	push	ecx
	sys.open
	jc	err

	add	esp, byte 12
	mov	[fd.out], eax

.init:
	sub	eax, eax
	sub	ebx, ebx
	sub	ecx, ecx
	mov	edi, obuffer

.loop:
	; read a byte from input file or stdin
	call	getchar

	; convert it to hex
	mov	dl, al
	shr	al, 4
	mov	al, [hex+eax]
	call	putchar

	mov	al, dl
	and	al, 0Fh
	mov	al, [hex+eax]
	call	putchar

	mov	al, ' '
	cmp	dl, 0Ah
	jne	.put
	mov	al, dl

.put:
	call	putchar
	cmp	al, dl
	jne	.loop
	call	write
	jmp	short .loop

align 4
getchar:
	or	ebx, ebx
	jne	.fetch

	call	read

.fetch:
	lodsb
	dec	ebx
	ret

read:
	push	dword BUFSIZE
	mov	esi, ibuffer
	push	esi
	push	dword [fd.in]
	sys.read
	add	esp, byte 12
	mov	ebx, eax
	or	eax, eax
	je	.done
	sub	eax, eax
	ret

align 4
.done:
	call	write		; flush output buffer

	; close files
	push	dword [fd.in]
	sys.close

	push	dword [fd.out]
	sys.close

	; return success
	push	dword 0
	sys.exit

align 4
putchar:
	stosb
	inc	ecx
	cmp	ecx, BUFSIZE
	je	write
	ret

align 4
write:
	sub	edi, ecx	; start of buffer
	push	ecx
	push	edi
	push	dword [fd.out]
	sys.write
	add	esp, byte 12
	sub	eax, eax
	sub	ecx, ecx	; buffer is empty now
	ret

En nuestra sección .data ahora tenemos dos nuevas variables, fd.in y fd.out. Aquí almacenamos los descriptores de archivo de entrada y de salida.

En la sección .text hemos reemplazado las referencias a stdin y stdout por [fd.in] y [fd.out].

La sección .text ahora comienza con un simple manejador de errores, que no hace más que salir del programa con un valor de retorno de 1. El manejador de errores está antes de _start para que estemos a poca distancia de donde ocurren los errores.

Naturalmente, la ejecución del programa sigue comenzando en _start. Primero, eliminamos argc y argv[0] de la pila: no nos interesan (en este programa, al menos).

Hacemos pop de argv[1] a ECX. Este registro es especialmente adecuado para punteros, ya que podemos manejar los punteros NULL con jecxz. Si argv[1] no es NULL, intentamos abrir el archivo nombrado en el primer argumento. De lo contrario, continuamos el programa como antes: leyendo de stdin, escribiendo en stdout. Si no conseguimos abrir el archivo de entrada (p. ej., no existe), saltamos al manejador de errores y salimos.

Si todo ha ido bien, ahora comprobamos el segundo argumento. Si está ahí, abrimos el archivo de salida. De lo contrario, enviamos la salida a stdout. Si no conseguimos abrir el archivo de salida (p. ej., existe y no tenemos permiso de escritura), saltamos, de nuevo, al manejador de errores.

El resto del código es igual que antes, salvo que cerramos los archivos de entrada y de salida antes de salir, y, como se ha mencionado, usamos [fd.in] y [fd.out].

Nuestro ejecutable tiene ahora unos nada despreciables 768 bytes de longitud.

¿Podemos mejorarlo aún más? ¡Por supuesto! Todo programa puede mejorarse. Aquí hay algunas ideas de lo que podríamos hacer:

  • Hacer que nuestro manejador de errores imprima un mensaje en stderr.

  • Añadir manejadores de errores a las funciones read y write.

  • Cerrar stdin cuando abrimos un archivo de entrada, stdout cuando abrimos un archivo de salida.

  • Añadir opciones de línea de comandos, como -i y -o, para poder listar los archivos de entrada y de salida en cualquier orden, o quizá leer de stdin y escribir en un archivo.

  • Imprimir un mensaje de uso si los argumentos de línea de comandos son incorrectos.

Dejaré estas mejoras como ejercicio para el lector: ya sabe todo lo que necesita saber para implementarlas.

10. El entorno UNIX

Un concepto importante de UNIX es el entorno, que está definido por las variables de entorno. Algunas las establece el sistema, otras usted, y otras el shell, o cualquier programa que cargue otro programa.

10.1. Cómo encontrar las variables de entorno

He dicho antes que cuando un programa comienza a ejecutarse, la pila contiene argc seguido del array argv terminado en NULL, seguido de algo más. El "algo más" es el entorno, o, para ser más precisos, un array terminado en NULL de punteros a las variables de entorno. A esto se le suele denominar env.

La estructura de env es la misma que la de argv, una lista de direcciones de memoria seguida de un NULL (0). En este caso, no hay ningún "envc": averiguamos dónde termina el array buscando el NULL final.

Las variables suelen venir en el formato name=value, pero a veces puede faltar la parte =value. Necesitamos tener en cuenta esa posibilidad.

10.2. webvars

Podría simplemente mostrarle algo de código que imprima el entorno de la misma forma que lo hace el comando env de UNIX. Pero he pensado que sería más interesante escribir una sencilla utilidad CGI en lenguaje ensamblador.

10.2.1. CGI: una visión rápida

Tengo un tutorial detallado de CGI en mi sitio web, pero aquí tiene una visión muy rápida de CGI:

  • El servidor web se comunica con el programa CGI estableciendo variables de entorno.

  • El programa CGI envía su salida a stdout. El servidor web la lee de ahí.

  • Debe comenzar con una cabecera HTTP seguida de dos líneas en blanco.

  • Luego imprime el código HTML, o cualquier otro tipo de datos que esté produciendo.

Aunque ciertas variables de entorno usan nombres estándar, otras varían, según el servidor web. Eso hace de webvars una herramienta de diagnóstico bastante útil.

10.2.2. El código

Nuestro programa webvars, entonces, debe enviar la cabecera HTTP seguida de algo de marcado HTML. Luego debe leer las variables de entorno una por una y enviarlas como parte de la página HTML.

El código es el siguiente. He colocado comentarios y explicaciones directamente dentro del código:

;;;;;;; webvars.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; Copyright (c) 2000 G. Adam Stanislav
; All rights reserved.
;
; Redistribution and use in source and binary forms, with or without
; modification, are permitted provided that the following conditions
; are met:
; 1. Redistributions of source code must retain the above copyright
;    notice, this list of conditions and the following disclaimer.
; 2. Redistributions in binary form must reproduce the above copyright
;    notice, this list of conditions and the following disclaimer in the
;    documentation and/or other materials provided with the distribution.
;
; THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
; ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
; IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
; ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
; FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
; DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
; OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
; HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
; LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
; OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
; SUCH DAMAGE.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; Version 1.0
;
; Started:	 8-Dec-2000
; Updated:	 8-Dec-2000
;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
%include	'system.inc'

section	.data
http	db	'Content-type: text/html', 0Ah, 0Ah
	db	'<?xml version="1.0" encoding="utf-8"?>', 0Ah
	db	'<!DOCTYPE html PUBLIC "-//W3C/DTD XHTML Strict//EN" '
	db	'"DTD/xhtml1-strict.dtd">', 0Ah
	db	'<html xmlns="https://www.w3.org/1999/xhtml" '
	db	'xml.lang="en" lang="en">', 0Ah
	db	'<head>', 0Ah
	db	'<title>Web Environment</title>', 0Ah
	db	'<meta name="author" content="G. Adam Stanislav" />', 0Ah
	db	'</head>', 0Ah, 0Ah
	db	'<body bgcolor="#ffffff" text="#000000" link="#0000ff" '
	db	'vlink="#840084" alink="#0000ff">', 0Ah
	db	'<div class="webvars">', 0Ah
	db	'<h1>Web Environment</h1>', 0Ah
	db	'<p>The following <b>environment variables</b> are defined '
	db	'on this web server:</p>', 0Ah, 0Ah
	db	'<table align="center" width="80" border="0" cellpadding="10" '
	db	'cellspacing="0" class="webvars">', 0Ah
httplen	equ	$-http
left	db	'<tr>', 0Ah
	db	'<td class="name"><tt>'
leftlen	equ	$-left
middle	db	'</tt></td>', 0Ah
	db	'<td class="value"><tt><b>'
midlen	equ	$-middle
undef	db	'<i>(undefined)</i>'
undeflen	equ	$-undef
right	db	'</b></tt></td>', 0Ah
	db	'</tr>', 0Ah
rightlen	equ	$-right
wrap	db	'</table>', 0Ah
	db	'</div>', 0Ah
	db	'</body>', 0Ah
	db	'</html>', 0Ah, 0Ah
wraplen	equ	$-wrap

section	.text
global	_start
_start:
	; First, send out all the http and xhtml stuff that is
	; needed before we start showing the environment
	push	dword httplen
	push	dword http
	push	dword stdout
	sys.write

	; Now find how far on the stack the environment pointers
	; are. We have 12 bytes we have pushed before "argc"
	mov	eax, [esp+12]

	; We need to remove the following from the stack:
	;
	;	The 12 bytes we pushed for sys.write
	;	The  4 bytes of argc
	;	The EAX*4 bytes of argv
	;	The  4 bytes of the NULL after argv
	;
	; Total:
	;	20 + eax * 4
	;
	; Because stack grows down, we need to ADD that many bytes
	; to ESP.
	lea	esp, [esp+20+eax*4]
	cld		; This should already be the case, but let's be sure.

	; Loop through the environment, printing it out
.loop:
	pop	edi
	or	edi, edi	; Done yet?
	je	near .wrap

	; Print the left part of HTML
	push	dword leftlen
	push	dword left
	push	dword stdout
	sys.write

	; It may be tempting to search for the '=' in the env string next.
	; But it is possible there is no '=', so we search for the
	; terminating NUL first.
	mov	esi, edi	; Save start of string
	sub	ecx, ecx
	not	ecx		; ECX = FFFFFFFF
	sub	eax, eax
repne	scasb
	not	ecx		; ECX = string length + 1
	mov	ebx, ecx	; Save it in EBX

	; Now is the time to find '='
	mov	edi, esi	; Start of string
	mov	al, '='
repne	scasb
	not	ecx
	add	ecx, ebx	; Length of name

	push	ecx
	push	esi
	push	dword stdout
	sys.write

	; Print the middle part of HTML table code
	push	dword midlen
	push	dword middle
	push	dword stdout
	sys.write

	; Find the length of the value
	not	ecx
	lea	ebx, [ebx+ecx-1]

	; Print "undefined" if 0
	or	ebx, ebx
	jne	.value

	mov	ebx, undeflen
	mov	edi, undef

.value:
	push	ebx
	push	edi
	push	dword stdout
	sys.write

	; Print the right part of the table row
	push	dword rightlen
	push	dword right
	push	dword stdout
	sys.write

	; Get rid of the 60 bytes we have pushed
	add	esp, byte 60

	; Get the next variable
	jmp	.loop

.wrap:
	; Print the rest of HTML
	push	dword wraplen
	push	dword wrap
	push	dword stdout
	sys.write

	; Return success
	push	dword 0
	sys.exit

Este código produce un ejecutable de 1.396 bytes. La mayor parte de él son datos, es decir, el marcado HTML que necesitamos enviar.

Ensámblelo y enlácelo como de costumbre:

% nasm -f elf webvars.asm
% ld -s -o webvars webvars.o

Para usarlo, necesita subir webvars a su servidor web. Según cómo esté configurado su servidor web, puede que tenga que almacenarlo en un directorio especial cgi-bin, o quizá renombrarlo con una extensión .cgi.

Luego necesita usar su navegador para ver su salida. Para ver su salida en mi servidor web, por favor vaya a http://www.int80h.org/webvars.

11. Trabajar con archivos

Ya hemos hecho algo de trabajo básico con archivos: sabemos cómo abrirlos y cerrarlos, cómo leerlos y escribirlos usando búferes. Pero UNIX ofrece mucha más funcionalidad en lo que respecta a los archivos. Examinaremos parte de ella en esta sección, y acabaremos con una bonita utilidad de conversión de archivos.

De hecho, empecemos por el final, es decir, con la utilidad de conversión de archivos. Siempre facilita la programación saber desde el principio qué se supone que debe hacer el producto final.

Uno de los primeros programas que escribí para UNIX fue converters/tuc, un conversor de archivos de texto a UNIX. Convierte un archivo de texto de otros sistemas operativos a un archivo de texto UNIX. En otras palabras, cambia de distintos tipos de finales de línea a la convención de nueva línea de UNIX. Guarda la salida en un archivo diferente. Opcionalmente, convierte un archivo de texto UNIX a un archivo de texto DOS.

He usado tuc exhaustivamente, pero siempre solo para convertir de algún otro SO a UNIX, nunca al revés. Siempre he deseado que simplemente sobrescribiera el archivo en lugar de tener yo que enviar la salida a un archivo diferente. La mayor parte del tiempo, acabo usándolo así:

% tuc myfile tempfile
% mv tempfile myfile

Estaría bien tener un ftuc, es decir, un tuc rápido, y usarlo así:

% ftuc myfile

En este artículo, entonces, escribiremos ftuc en lenguaje ensamblador (el tuc original está en C), y estudiaremos varios servicios del kernel orientados a archivos en el proceso.

A primera vista, una conversión de archivos así es muy sencilla: todo lo que tiene que hacer es eliminar los retornos de carro, ¿verdad?

Si ha respondido que sí, piénselo de nuevo: ese enfoque funcionará la mayoría de las veces (al menos con archivos de texto de MS DOS), pero fallará de vez en cuando.

El problema es que no todos los archivos de texto que no son de UNIX terminan su línea con la secuencia de retorno de carro / avance de línea. Algunos usan retornos de carro sin avances de línea. Otros combinan varias líneas en blanco en un único retorno de carro seguido de varios avances de línea. Y así sucesivamente.

Un conversor de archivos de texto, entonces, debe ser capaz de manejar cualquier posible final de línea:

  • retorno de carro / avance de línea

  • retorno de carro

  • avance de línea / retorno de carro

  • avance de línea

También debería manejar archivos que usen algún tipo de combinación de los anteriores (p. ej., un retorno de carro seguido de varios avances de línea).

11.1. Máquina de estados finitos

El problema se resuelve fácilmente usando una técnica llamada máquina de estados finitos, desarrollada originalmente por los diseñadores de circuitos electrónicos digitales. Una máquina de estados finitos es un circuito digital cuya salida depende no solo de su entrada sino de su entrada previa, es decir, de su estado. El microprocesador es un ejemplo de máquina de estados finitos: nuestro código en lenguaje ensamblador se ensambla a lenguaje máquina en el que cierto código en lenguaje ensamblador produce un solo byte de lenguaje máquina, mientras que otro produce varios bytes. A medida que el microprocesador obtiene los bytes de la memoria uno a uno, algunos de ellos simplemente cambian su estado en lugar de producir alguna salida. Cuando se han obtenido todos los bytes del código de operación, el microprocesador produce alguna salida, o cambia el valor de un registro, etc.

Debido a eso, todo el software es esencialmente una secuencia de instrucciones de estado para el microprocesador. No obstante, el concepto de máquina de estados finitos también es útil en el diseño de software.

Nuestro conversor de archivos de texto puede diseñarse como una máquina de estados finitos con tres estados posibles. Podríamos llamarlos estados 0-2, pero nos facilitará la vida si les damos nombres simbólicos:

  • ordinary

  • cr

  • lf

Nuestro programa comenzará en el estado ordinary. Durante este estado, la acción del programa depende de su entrada de la siguiente manera:

  • Si la entrada es cualquier cosa distinta de un retorno de carro o un avance de línea, la entrada simplemente se pasa a la salida. El estado permanece sin cambios.

  • Si la entrada es un retorno de carro, el estado se cambia a cr. La entrada se descarta entonces, es decir, no se produce ninguna salida.

  • Si la entrada es un avance de línea, el estado se cambia a lf. La entrada se descarta entonces.

Siempre que estamos en el estado cr, es porque la última entrada fue un retorno de carro, que quedó sin procesar. Lo que hace nuestro software en este estado depende de nuevo de la entrada actual:

  • Si la entrada es cualquier cosa distinta de un retorno de carro o un avance de línea, se produce un avance de línea, luego se produce la entrada, y luego se cambia el estado a ordinary.

  • Si la entrada es un retorno de carro, hemos recibido dos (o más) retornos de carro seguidos. Descartamos la entrada, producimos un avance de línea, y dejamos el estado sin cambios.

  • Si la entrada es un avance de línea, producimos el avance de línea y cambiamos el estado a ordinary. Observe que esto no es lo mismo que el primer caso anterior: si intentáramos combinarlos, estaríamos produciendo dos avances de línea en lugar de uno.

Finalmente, estamos en el estado lf después de haber recibido un avance de línea que no estuvo precedido por un retorno de carro. Esto ocurrirá cuando nuestro archivo ya esté en formato UNIX, o siempre que varias líneas seguidas se expresen mediante un único retorno de carro seguido de varios avances de línea, o cuando una línea termine con una secuencia de avance de línea / retorno de carro. Así es como necesitamos manejar nuestra entrada en este estado:

  • Si la entrada es cualquier cosa distinta de un retorno de carro o un avance de línea, producimos un avance de línea, luego producimos la entrada, y luego cambiamos el estado a ordinary. Esta es exactamente la misma acción que en el estado cr al recibir el mismo tipo de entrada.

  • Si la entrada es un retorno de carro, descartamos la entrada, producimos un avance de línea, y luego cambiamos el estado a ordinary.

  • Si la entrada es un avance de línea, producimos el avance de línea, y dejamos el estado sin cambios.

11.1.1. El estado final

La máquina de estados finitos anterior funciona para todo el archivo, pero deja la posibilidad de que se ignore el final de la última línea. Eso ocurrirá siempre que el archivo termine con un único retorno de carro o un único avance de línea. No pensé en ello cuando escribí tuc, solo para descubrir que de vez en cuando elimina el final de la última línea.

Este problema se arregla fácilmente comprobando el estado después de haber procesado todo el archivo. Si el estado no es ordinary, simplemente necesitamos producir un último avance de línea.

Ahora que hemos expresado nuestro algoritmo como una máquina de estados finitos, podríamos diseñar fácilmente un circuito electrónico digital dedicado (un "chip") que hiciera la conversión por nosotros. Por supuesto, hacerlo sería considerablemente más caro que escribir un programa en lenguaje ensamblador.

11.1.2. El contador de salida

Como nuestro programa de conversión de archivos puede estar combinando dos caracteres en uno, necesitamos usar un contador de salida. Lo inicializamos a 0, y lo incrementamos cada vez que enviamos un carácter a la salida. Al final del programa, el contador nos dirá a qué tamaño necesitamos ajustar el archivo.

11.2. Implementar una FSM en software

La parte más difícil de trabajar con una máquina de estados finitos es analizar el problema y expresarlo como una máquina de estados finitos. Logrado eso, el software casi se escribe solo.

En un lenguaje de alto nivel, como C, hay varios enfoques principales. Uno es usar una sentencia switch que elige qué función debe ejecutarse. Por ejemplo,

switch (state) {
	default:
	case REGULAR:
		regular(inputchar);
		break;
	case CR:
		cr(inputchar);
		break;
	case LF:
		lf(inputchar);
		break;
	}

Otro enfoque es usar un array de punteros a función, algo así:

(output[state])(inputchar);

Otro más es hacer que state sea un puntero a función, configurado para apuntar a la función apropiada:

(*state)(inputchar);

Este es el enfoque que usaremos en nuestro programa porque es muy fácil de hacer en lenguaje ensamblador, y muy rápido, además. Simplemente mantendremos la dirección del procedimiento correcto en EBX, y luego solo emitiremos:

call	ebx

Esto es posiblemente más rápido que codificar la dirección directamente en el código porque el microprocesador no tiene que obtener la dirección de la memoria: ya está almacenada en uno de sus registros. He dicho posiblemente porque con el almacenamiento en caché que hacen los microprocesadores modernos, ambas formas pueden ser igual de rápidas.

11.3. Archivos mapeados en memoria

Como nuestro programa trabaja sobre un único archivo, no podemos usar el enfoque que nos funcionó antes, es decir, leer de un archivo de entrada y escribir en un archivo de salida.

UNIX nos permite mapear un archivo, o una sección de un archivo, en memoria. Para hacer eso, primero necesitamos abrir el archivo con los indicadores de lectura/escritura apropiados. Luego usamos la llamada al sistema mmap para mapearlo en la memoria. Una cosa buena de mmap es que funciona automáticamente con la memoria virtual: Podemos mapear en la memoria más del archivo del que tenemos memoria física disponible, y aun así acceder a él mediante códigos de operación de memoria normales, como mov, lods, y stos. Cualesquiera cambios que hagamos a la imagen en memoria del archivo serán escritos al archivo por el sistema. Ni siquiera tenemos que mantener el archivo abierto: mientras permanezca mapeado, podemos leer de él y escribir en él.

Los microprocesadores Intel de 32 bits pueden acceder hasta a cuatro gigabytes de memoria, física o virtual. El sistema FreeBSD nos permite usar hasta la mitad de ella para el mapeo de archivos.

En aras de la simplicidad, en este tutorial solo convertiremos archivos que puedan mapearse en la memoria en su totalidad. Probablemente no haya demasiados archivos de texto que superen los dos gigabytes de tamaño. Si nuestro programa encuentra uno, simplemente mostrará un mensaje sugiriendo que usemos el tuc original en su lugar.

Si examina su copia de syscalls.master, encontrará dos syscalls separadas llamadas mmap. Esto se debe a la evolución de UNIX: existía el mmap tradicional de BSD, la syscall 71. Esa fue reemplazada por el mmap de POSIX®, la syscall 197. El sistema FreeBSD admite ambas porque los programas más antiguos se escribieron usando la versión BSD original. Pero el software nuevo usa la versión POSIX, que es la que usaremos.

El syscalls.master lista la versión POSIX así:

197	STD	BSD	{ caddr_t mmap(caddr_t addr, size_t len, int prot, \
			    int flags, int fd, long pad, off_t pos); }

Esto difiere ligeramente de lo que dice mmap(2). Eso se debe a que mmap(2) describe la versión de C.

La diferencia está en el argumento long pad, que no está presente en la versión de C. Sin embargo, las syscalls de FreeBSD añaden un relleno de 32 bits después de hacer push de un argumento de 64 bits. En este caso, off_t es un valor de 64 bits.

Cuando terminamos de trabajar con un archivo mapeado en memoria, lo desmapeamos con la syscall munmap:

Para un tratamiento en profundidad de mmap, consulte Unix Network Programming de W. Richard Stevens, Volumen 2, Capítulo 12.

11.4. Determinar el tamaño del archivo

Como necesitamos indicarle a mmap cuántos bytes del archivo mapear en la memoria, y como queremos mapear el archivo entero, necesitamos determinar el tamaño del archivo.

Podemos usar la syscall fstat para obtener toda la información sobre un archivo abierto que el sistema pueda darnos. Eso incluye el tamaño del archivo.

De nuevo, syscalls.master lista dos versiones de fstat, una tradicional (syscall 62), y una POSIX (syscall 189). Naturalmente, usaremos la versión POSIX:

189	STD	POSIX	{ int fstat(int fd, struct stat *sb); }

Esta es una llamada muy sencilla: le pasamos la dirección de una estructura stat y el descriptor de un archivo abierto. Rellenará el contenido de la estructura stat.

Sin embargo, tengo que decir que intenté declarar la estructura stat en la sección .bss, y a fstat no le gustó: estableció el carry flag indicando un error. Después de cambiar el código para asignar la estructura en la pila, todo funcionaba bien.

11.5. Cambiar el tamaño del archivo

Como nuestro programa puede combinar secuencias de retorno de carro / avance de línea en simples avances de línea, nuestra salida puede ser más pequeña que nuestra entrada. Sin embargo, como estamos colocando nuestra salida en el mismo archivo del que leemos la entrada, puede que tengamos que cambiar el tamaño del archivo.

La llamada al sistema ftruncate nos permite hacer precisamente eso. A pesar de su nombre algo engañoso, la llamada al sistema ftruncate puede usarse tanto para truncar el archivo (hacerlo más pequeño) como para agrandarlo.

Y sí, encontraremos dos versiones de ftruncate en syscalls.master, una más antigua (130), y una más nueva (201). Usaremos la más nueva:

201	STD	BSD	{ int ftruncate(int fd, int pad, off_t length); }

Tenga en cuenta que esta contiene de nuevo un int pad.

11.6. ftuc

Ahora sabemos todo lo que necesitamos para escribir ftuc. Empezamos añadiendo algunas líneas nuevas en system.inc. Primero, definimos algunas constantes y estructuras, en algún lugar al principio del archivo o cerca de él:

;;;;;;; open flags
%define	O_RDONLY	0
%define	O_WRONLY	1
%define	O_RDWR	2

;;;;;;; mmap flags
%define	PROT_NONE	0
%define	PROT_READ	1
%define	PROT_WRITE	2
%define	PROT_EXEC	4
;;
%define	MAP_SHARED	0001h
%define	MAP_PRIVATE	0002h

;;;;;;; stat structure
struc	stat
st_dev		resd	1	; = 0
st_ino		resd	1	; = 4
st_mode		resw	1	; = 8, size is 16 bits
st_nlink	resw	1	; = 10, ditto
st_uid		resd	1	; = 12
st_gid		resd	1	; = 16
st_rdev		resd	1	; = 20
st_atime	resd	1	; = 24
st_atimensec	resd	1	; = 28
st_mtime	resd	1	; = 32
st_mtimensec	resd	1	; = 36
st_ctime	resd	1	; = 40
st_ctimensec	resd	1	; = 44
st_size		resd	2	; = 48, size is 64 bits
st_blocks	resd	2	; = 56, ditto
st_blksize	resd	1	; = 64
st_flags	resd	1	; = 68
st_gen		resd	1	; = 72
st_lspare	resd	1	; = 76
st_qspare	resd	4	; = 80
endstruc

Definimos las nuevas syscalls:

%define	SYS_mmap	197
%define	SYS_munmap	73
%define	SYS_fstat	189
%define	SYS_ftruncate	201

Añadimos las macros para su uso:

%macro	sys.mmap	0
	system	SYS_mmap
%endmacro

%macro	sys.munmap	0
	system	SYS_munmap
%endmacro

%macro	sys.ftruncate	0
	system	SYS_ftruncate
%endmacro

%macro	sys.fstat	0
	system	SYS_fstat
%endmacro

Y aquí está nuestro código:

;;;;;;; Fast Text-to-Unix Conversion (ftuc.asm) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;;
;; Started:	21-Dec-2000
;; Updated:	22-Dec-2000
;;
;; Copyright 2000 G. Adam Stanislav.
;; All rights reserved.
;;
;;;;;;; v.1 ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
%include	'system.inc'

section	.data
	db	'Copyright 2000 G. Adam Stanislav.', 0Ah
	db	'All rights reserved.', 0Ah
usg	db	'Usage: ftuc filename', 0Ah
usglen	equ	$-usg
co	db	"ftuc: Can't open file.", 0Ah
colen	equ	$-co
fae	db	'ftuc: File access error.', 0Ah
faelen	equ	$-fae
ftl	db	'ftuc: File too long, use regular tuc instead.', 0Ah
ftllen	equ	$-ftl
mae	db	'ftuc: Memory allocation error.', 0Ah
maelen	equ	$-mae

section	.text

align 4
memerr:
	push	dword maelen
	push	dword mae
	jmp	short error

align 4
toolong:
	push	dword ftllen
	push	dword ftl
	jmp	short error

align 4
facerr:
	push	dword faelen
	push	dword fae
	jmp	short error

align 4
cantopen:
	push	dword colen
	push	dword co
	jmp	short error

align 4
usage:
	push	dword usglen
	push	dword usg

error:
	push	dword stderr
	sys.write

	push	dword 1
	sys.exit

align 4
global	_start
_start:
	pop	eax		; argc
	pop	eax		; program name
	pop	ecx		; file to convert
	jecxz	usage

	pop	eax
	or	eax, eax	; Too many arguments?
	jne	usage

	; Open the file
	push	dword O_RDWR
	push	ecx
	sys.open
	jc	cantopen

	mov	ebp, eax	; Save fd

	sub	esp, byte stat_size
	mov	ebx, esp

	; Find file size
	push	ebx
	push	ebp		; fd
	sys.fstat
	jc	facerr

	mov	edx, [ebx + st_size + 4]

	; File is too long if EDX != 0 ...
	or	edx, edx
	jne	near toolong
	mov	ecx, [ebx + st_size]
	; ... or if it is above 2 GB
	or	ecx, ecx
	js	near toolong

	; Do nothing if the file is 0 bytes in size
	jecxz	.quit

	; Map the entire file in memory
	push	edx
	push	edx		; starting at offset 0
	push	edx		; pad
	push	ebp		; fd
	push	dword MAP_SHARED
	push	dword PROT_READ | PROT_WRITE
	push	ecx		; entire file size
	push	edx		; let system decide on the address
	sys.mmap
	jc	near memerr

	mov	edi, eax
	mov	esi, eax
	push	ecx		; for SYS_munmap
	push	edi

	; Use EBX for state machine
	mov	ebx, ordinary
	mov	ah, 0Ah
	cld

.loop:
	lodsb
	call	ebx
	loop	.loop

	cmp	ebx, ordinary
	je	.filesize

	; Output final lf
	mov	al, ah
	stosb
	inc	edx

.filesize:
	; truncate file to new size
	push	dword 0		; high dword
	push	edx		; low dword
	push	eax		; pad
	push	ebp
	sys.ftruncate

	; close it (ebp still pushed)
	sys.close

	add	esp, byte 16
	sys.munmap

.quit:
	push	dword 0
	sys.exit

align 4
ordinary:
	cmp	al, 0Dh
	je	.cr

	cmp	al, ah
	je	.lf

	stosb
	inc	edx
	ret

align 4
.cr:
	mov	ebx, cr
	ret

align 4
.lf:
	mov	ebx, lf
	ret

align 4
cr:
	cmp	al, 0Dh
	je	.cr

	cmp	al, ah
	je	.lf

	xchg	al, ah
	stosb
	inc	edx

	xchg	al, ah
	; fall through

.lf:
	stosb
	inc	edx
	mov	ebx, ordinary
	ret

align 4
.cr:
	mov	al, ah
	stosb
	inc	edx
	ret

align 4
lf:
	cmp	al, ah
	je	.lf

	cmp	al, 0Dh
	je	.cr

	xchg	al, ah
	stosb
	inc	edx

	xchg	al, ah
	stosb
	inc	edx
	mov	ebx, ordinary
	ret

align 4
.cr:
	mov	ebx, ordinary
	mov	al, ah
	; fall through

.lf:
	stosb
	inc	edx
	ret

No use este programa en archivos almacenados en un disco formateado por MS-DOS o Windows. Parece haber un error sutil en el código de FreeBSD al usar mmap en estas unidades montadas bajo FreeBSD: Si el archivo supera un cierto tamaño, mmap simplemente llenará la memoria con ceros, y luego los copiará al archivo sobrescribiendo su contenido.

12. Mente concentrada en un punto

Como estudiante de Zen, me gusta la idea de una mente concentrada en un punto: hacer una cosa a la vez, y hacerla bien.

Esto, de hecho, es muy parecido a cómo funciona UNIX también. Mientras que una aplicación típica de Windows intenta hacer todo lo imaginable (y está, por tanto, plagada de errores), un programa típico de UNIX hace solo una cosa, y la hace bien.

El usuario típico de UNIX ensambla entonces, en esencia, sus propias aplicaciones escribiendo un shell script que combina los diversos programas existentes canalizando la salida de un programa a la entrada de otro.

Al escribir su propio software para UNIX, generalmente es buena idea ver qué partes del problema que necesita resolver pueden manejarse con programas existentes, y escribir sus propios programas solo para la parte del problema para la que no tiene una solución existente.

12.1. CSV

Ilustraré este principio con un ejemplo concreto de la vida real al que me enfrenté recientemente:

Necesitaba extraer el undécimo campo de cada registro de una base de datos que descargué de un sitio web. La base de datos era un archivo CSV, es decir, una lista de valores separados por comas. Ese es un formato bastante estándar para compartir datos entre personas que pueden estar usando software de bases de datos diferente.

La primera línea del archivo contiene la lista de los diversos campos separados por comas. El resto del archivo contiene los datos listados línea por línea, con los valores separados por comas.

Probé con awk, usando la coma como separador. Pero como varias líneas contenían una coma entrecomillada, awk extraía el campo equivocado de esas líneas.

Por lo tanto, necesitaba escribir mi propio software para extraer el undécimo campo del archivo CSV. Sin embargo, siguiendo el espíritu de UNIX, solo necesitaba escribir un filtro sencillo que hiciera lo siguiente:

  • Eliminar la primera línea del archivo;

  • Cambiar todas las comas no entrecomilladas por un carácter diferente;

  • Eliminar todas las comillas.

Estrictamente hablando, podría usar sed para eliminar la primera línea del archivo, pero hacerlo en mi propio programa era muy fácil, así que decidí hacerlo y reducir el tamaño de la tubería.

En cualquier caso, escribir un programa como este me llevó unos 20 minutos. Escribir un programa que extrajera el undécimo campo del archivo CSV llevaría mucho más tiempo, y no podría reutilizarlo para extraer algún otro campo de alguna otra base de datos.

Esta vez decidí dejar que hiciera un poco más de trabajo del que haría un programa de tutorial típico:

  • Analiza su línea de comandos en busca de opciones;

  • Muestra el uso correcto si encuentra argumentos erróneos;

  • Produce mensajes de error significativos.

Aquí está su mensaje de uso:

Usage: csv [-t<delim>] [-c<comma>] [-p] [-o <outfile>] [-i <infile>]

Todos los parámetros son opcionales, y pueden aparecer en cualquier orden.

El parámetro -t declara por qué reemplazar las comas. El tab es el valor por defecto aquí. Por ejemplo, -t; reemplazará todas las comas no entrecomilladas por puntos y comas.

No necesitaba la opción -c, pero puede resultar útil en el futuro. Me permite declarar que quiero que un carácter distinto de una coma sea reemplazado por otra cosa. Por ejemplo, -c@ reemplazará todos los signos de arroba (útil si quiere dividir una lista de direcciones de correo electrónico en sus nombres de usuario y dominios).

La opción -p preserva la primera línea, es decir, no la elimina. Por defecto, eliminamos la primera línea porque en un archivo CSV contiene los nombres de los campos en lugar de datos.

Las opciones -i y -o me permiten especificar los archivos de entrada y de salida. Los valores por defecto son stdin y stdout, así que este es un filtro UNIX normal.

Me aseguré de que se aceptaran tanto -i filename como -ifilename. También me aseguré de que solo pudiera especificarse un archivo de entrada y uno de salida.

Para obtener el undécimo campo de cada registro, ahora puedo hacer:

% csv '-t;' data.csv | awk '-F;' '{print $11}'

El código almacena las opciones (salvo los descriptores de archivo) en EDX: la coma en DH, el nuevo separador en DL, y el indicador de la opción -p en el bit más alto de EDX, de modo que una comprobación de su signo nos dará una decisión rápida sobre qué hacer.

Aquí está el código:

;;;;;;; csv.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; Convert a comma-separated file to a something-else separated file.
;
; Started:	31-May-2001
; Updated:	 1-Jun-2001
;
; Copyright (c) 2001 G. Adam Stanislav
; All rights reserved.
;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

%include	'system.inc'

%define	BUFSIZE	2048

section	.data
fd.in	dd	stdin
fd.out	dd	stdout
usg	db	'Usage: csv [-t<delim>] [-c<comma>] [-p] [-o <outfile>] [-i <infile>]', 0Ah
usglen	equ	$-usg
iemsg	db	"csv: Can't open input file", 0Ah
iemlen	equ	$-iemsg
oemsg	db	"csv: Can't create output file", 0Ah
oemlen	equ	$-oemsg

section .bss
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE

section	.text
align 4
ierr:
	push	dword iemlen
	push	dword iemsg
	push	dword stderr
	sys.write
	push	dword 1		; return failure
	sys.exit

align 4
oerr:
	push	dword oemlen
	push	dword oemsg
	push	dword stderr
	sys.write
	push	dword 2
	sys.exit

align 4
usage:
	push	dword usglen
	push	dword usg
	push	dword stderr
	sys.write
	push	dword 3
	sys.exit

align 4
global	_start
_start:
	add	esp, byte 8	; discard argc and argv[0]
	mov	edx, (',' << 8) | 9

.arg:
	pop	ecx
	or	ecx, ecx
	je	near .init		; no more arguments

	; ECX contains the pointer to an argument
	cmp	byte [ecx], '-'
	jne	usage

	inc	ecx
	mov	ax, [ecx]

.o:
	cmp	al, 'o'
	jne	.i

	; Make sure we are not asked for the output file twice
	cmp	dword [fd.out], stdout
	jne	usage

	; Find the path to output file - it is either at [ECX+1],
	; i.e., -ofile --
	; or in the next argument,
	; i.e., -o file

	inc	ecx
	or	ah, ah
	jne	.openoutput
	pop	ecx
	jecxz	usage

.openoutput:
	push	dword 420	; file mode (644 octal)
	push	dword 0200h | 0400h | 01h
	; O_CREAT | O_TRUNC | O_WRONLY
	push	ecx
	sys.open
	jc	near oerr

	add	esp, byte 12
	mov	[fd.out], eax
	jmp	short .arg

.i:
	cmp	al, 'i'
	jne	.p

	; Make sure we are not asked twice
	cmp	dword [fd.in], stdin
	jne	near usage

	; Find the path to the input file
	inc	ecx
	or	ah, ah
	jne	.openinput
	pop	ecx
	or	ecx, ecx
	je near usage

.openinput:
	push	dword 0		; O_RDONLY
	push	ecx
	sys.open
	jc	near ierr		; open failed

	add	esp, byte 8
	mov	[fd.in], eax
	jmp	.arg

.p:
	cmp	al, 'p'
	jne	.t
	or	ah, ah
	jne	near usage
	or	edx, 1 << 31
	jmp	.arg

.t:
	cmp	al, 't'		; redefine output delimiter
	jne	.c
	or	ah, ah
	je	near usage
	mov	dl, ah
	jmp	.arg

.c:
	cmp	al, 'c'
	jne	near usage
	or	ah, ah
	je	near usage
	mov	dh, ah
	jmp	.arg

align 4
.init:
	sub	eax, eax
	sub	ebx, ebx
	sub	ecx, ecx
	mov	edi, obuffer

	; See if we are to preserve the first line
	or	edx, edx
	js	.loop

.firstline:
	; get rid of the first line
	call	getchar
	cmp	al, 0Ah
	jne	.firstline

.loop:
	; read a byte from stdin
	call	getchar

	; is it a comma (or whatever the user asked for)?
	cmp	al, dh
	jne	.quote

	; Replace the comma with a tab (or whatever the user wants)
	mov	al, dl

.put:
	call	putchar
	jmp	short .loop

.quote:
	cmp	al, '"'
	jne	.put

	; Print everything until you get another quote or EOL. If it
	; is a quote, skip it. If it is EOL, print it.
.qloop:
	call	getchar
	cmp	al, '"'
	je	.loop

	cmp	al, 0Ah
	je	.put

	call	putchar
	jmp	short .qloop

align 4
getchar:
	or	ebx, ebx
	jne	.fetch

	call	read

.fetch:
	lodsb
	dec	ebx
	ret

read:
	jecxz	.read
	call	write

.read:
	push	dword BUFSIZE
	mov	esi, ibuffer
	push	esi
	push	dword [fd.in]
	sys.read
	add	esp, byte 12
	mov	ebx, eax
	or	eax, eax
	je	.done
	sub	eax, eax
	ret

align 4
.done:
	call	write		; flush output buffer

	; close files
	push	dword [fd.in]
	sys.close

	push	dword [fd.out]
	sys.close

	; return success
	push	dword 0
	sys.exit

align 4
putchar:
	stosb
	inc	ecx
	cmp	ecx, BUFSIZE
	je	write
	ret

align 4
write:
	jecxz	.ret	; nothing to write
	sub	edi, ecx	; start of buffer
	push	ecx
	push	edi
	push	dword [fd.out]
	sys.write
	add	esp, byte 12
	sub	eax, eax
	sub	ecx, ecx	; buffer is empty now
.ret:
	ret

Gran parte de él está tomado del hex.asm anterior. Pero hay una diferencia importante: ya no llamo a write cada vez que produzco un avance de línea. Aun así, el código puede usarse de forma interactiva.

He encontrado una solución mejor para el problema interactivo desde que empecé a escribir este artículo. Quería asegurarme de que cada línea se imprima por separado solo cuando sea necesario. Al fin y al cabo, no hay necesidad de volcar cada línea cuando se usa de forma no interactiva.

La nueva solución que uso ahora es llamar a write cada vez que encuentro el búfer de entrada vacío. De esa forma, al ejecutarse en modo interactivo, el programa lee una línea del teclado del usuario, la procesa, y ve que su búfer de entrada está vacío. Vuelca su salida y lee la siguiente línea.

12.1.1. El lado oscuro del uso de búferes

Este cambio evita un bloqueo misterioso en un caso muy específico. Me refiero a él como el lado oscuro del uso de búferes, principalmente porque presenta un peligro que no es del todo obvio.

Es poco probable que ocurra con un programa como el csv anterior, así que consideremos otro filtro más: En este caso esperamos que nuestra entrada sean datos en bruto que representan valores de color, como las intensidades de rojo, verde, y azul de un píxel. Nuestra salida será el negativo de nuestra entrada.

Un filtro así sería muy sencillo de escribir. La mayor parte de él tendría el mismo aspecto que todos los demás filtros que hemos escrito hasta ahora, así que solo voy a mostrarle su bucle interno:

.loop:
	call	getchar
	not	al		; Create a negative
	call	putchar
	jmp	short .loop

Como este filtro trabaja con datos en bruto, es poco probable que se use de forma interactiva.

Pero podría ser llamado por software de manipulación de imágenes. Y, a menos que llame a write antes de cada llamada a read, lo más probable es que se bloquee.

Esto es lo que podría ocurrir:

  1. El editor de imágenes cargará nuestro filtro usando la función C popen().

  2. Leerá la primera fila de píxeles de un bitmap o pixmap.

  3. Escribirá la primera fila de píxeles en la tubería que lleva al fd.in de nuestro filtro.

  4. Nuestro filtro leerá cada píxel de su entrada, lo convertirá en negativo, y lo escribirá en su búfer de salida.

  5. Nuestro filtro llamará a getchar para obtener el siguiente píxel.

  6. getchar encontrará un búfer de entrada vacío, así que llamará a read.

  7. read llamará a la llamada al sistema SYS_read.

  8. El kernel suspenderá nuestro filtro hasta que el editor de imágenes envíe más datos a la tubería.

  9. El editor de imágenes leerá de la otra tubería, conectada al fd.out de nuestro filtro, para poder establecer la primera fila de la imagen de salida antes de enviarnos la segunda fila de la entrada.

  10. El kernel suspende el editor de imágenes hasta que recibe alguna salida de nuestro filtro, para poder pasársela al editor de imágenes.

En este punto nuestro filtro espera a que el editor de imágenes le envíe más datos que procesar, mientras que el editor de imágenes está esperando a que nuestro filtro le envíe el resultado del procesamiento de la primera fila. Pero el resultado está en nuestro búfer de salida.

El filtro y el editor de imágenes seguirán esperándose mutuamente para siempre (o, al menos, hasta que se los mate). Nuestro software acaba de entrar en una condición de carrera.

Este problema no existe si nuestro filtro vuelca su búfer de salida antes de pedir al kernel más datos de entrada.

13. Usar la FPU

Por extraño que parezca, la mayor parte de la literatura sobre lenguaje ensamblador ni siquiera menciona la existencia de la FPU, o unidad de coma flotante, y mucho menos trata su programación.

Sin embargo, el lenguaje ensamblador nunca brilla más que cuando creamos código de FPU altamente optimizado haciendo cosas que pueden hacerse solo en lenguaje ensamblador.

13.1. Organización de la FPU

La FPU consta de 8 registros de coma flotante de 80 bits. Estos están organizados a modo de pila: puede hacer push de un valor en la TOS (cima de la pila) y puede hacer pop de él.

Dicho esto, los códigos de operación del lenguaje ensamblador no son push y pop porque esos ya están ocupados.

Puede hacer push de un valor en la TOS usando fld, fild, y fbld. Varios otros códigos de operación le permiten hacer push de muchas constantes comunes -como pi- en la TOS.

De forma similar, puede hacer pop de un valor usando fst, fstp, fist, fistp, y fbstp. En realidad, solo los códigos de operación que terminan en p harán literalmente pop del valor, el resto lo almacenarán en otro lugar sin quitarlo de la TOS.

Podemos transferir los datos entre la TOS y la memoria del ordenador bien como un real de 32, 64, u 80 bits, un entero de 16, 32, o 64 bits, o un decimal empaquetado de 80 bits.

El decimal empaquetado de 80 bits es un caso especial del decimal codificado en binario que es muy conveniente al convertir entre la representación ASCII de los datos y los datos internos de la FPU. Nos permite usar 18 dígitos significativos.

Independientemente de cómo representemos los datos en la memoria, la FPU siempre los almacena en el formato real de 80 bits en sus registros.

Su precisión interna es de al menos 19 dígitos decimales, así que incluso si elegimos mostrar los resultados como ASCII con la precisión completa de 18 dígitos, seguimos mostrando resultados correctos.

Podemos realizar operaciones matemáticas sobre la TOS: podemos calcular su seno, podemos escalarla (es decir, podemos multiplicarla o dividirla por una potencia de 2), podemos calcular su logaritmo en base 2, y muchas otras cosas.

También podemos multiplicarla o dividirla por, sumarla a, o restarla de, cualquiera de los registros de la FPU (incluido él mismo).

El código de operación oficial de Intel para la TOS es st, y para los registros st(0)-st(7). st y st(0), entonces, se refieren al mismo registro.

Por las razones que sean, el autor original de nasm decidió usar códigos de operación diferentes, a saber st0-st7. En otras palabras, no hay paréntesis, y la TOS siempre es st0, nunca solo st.

13.1.1. El formato decimal empaquetado

El formato decimal empaquetado usa 10 bytes (80 bits) de memoria para representar 18 dígitos. El número representado ahí es siempre un entero.

Puede usarlo para obtener decimales multiplicando primero la TOS por una potencia de 10.

El bit más alto del byte más alto (byte 9) es el bit de signo: si está establecido, el número es negativo, de lo contrario, es positivo. El resto de los bits de este byte no se usan/se ignoran.

Los 9 bytes restantes almacenan los 18 dígitos del número: 2 dígitos por byte.

El dígito más significativo se almacena en el nibble alto (4 bits), el dígito menos significativo en el nibble bajo.

Dicho esto, podría pensar que -1234567 se almacenaría en la memoria así (usando notación hexadecimal):

80 00 00 00 00 00 01 23 45 67

¡Pues no! Como todo lo demás de fabricación Intel, incluso el decimal empaquetado es little-endian.

Eso significa que nuestro -1234567 se almacena así:

67 45 23 01 00 00 00 00 00 80

¡Recuérdelo, o se estará tirando de los pelos de desesperación!

El libro a leer -si puede encontrarlo- es 8087/80287/80387 for the IBM PC & Compatibles de Richard Startz. Aunque parece dar por sentado el hecho del almacenamiento little-endian del decimal empaquetado. No bromeo sobre la desesperación de intentar averiguar qué fallaba en el filtro que muestro más abajo antes de que se me ocurriera que debería probar el orden little-endian incluso para este tipo de datos.

13.2. Una excursión a la fotografía estenopeica

Para escribir software con sentido, no solo debemos entender nuestras herramientas de programación, sino también el campo para el que estamos creando software.

Nuestro próximo filtro nos ayudará siempre que queramos construir una cámara estenopeica, así que necesitamos algunos conocimientos previos sobre fotografía estenopeica antes de poder continuar.

13.2.1. La cámara

La forma más sencilla de describir cualquier cámara jamás construida es como un espacio vacío encerrado en algún material opaco a la luz, con un pequeño orificio en el recinto.

El recinto suele ser robusto (p. ej., una caja), aunque a veces es flexible (el fuelle). El interior de la cámara es bastante oscuro. Sin embargo, el orificio deja entrar los rayos de luz a través de un único punto (aunque en algunos casos puede haber varios). Estos rayos de luz forman una imagen, una representación de lo que sea que esté fuera de la cámara, delante del orificio.

Si se coloca algún material sensible a la luz (como una película) dentro de la cámara, puede capturar la imagen.

El orificio contiene a menudo una lente, o un conjunto de lentes, a menudo llamado el objetivo.

13.2.2. El estenopo

Pero, estrictamente hablando, la lente no es necesaria: las cámaras originales no usaban una lente sino un estenopo. Incluso hoy, los estenopos se usan, tanto como herramienta para estudiar cómo funcionan las cámaras, como para lograr un tipo especial de imagen.

La imagen producida por el estenopo es toda igual de nítida. O borrosa. Hay un tamaño ideal para un estenopo: si es mayor o menor, la imagen pierde su nitidez.

13.2.3. Distancia focal

Este diámetro ideal del estenopo es función de la raíz cuadrada de la distancia focal, que es la distancia del estenopo a la película.

D = PC * sqrt(FL)

Aquí, D es el diámetro ideal del estenopo, FL es la distancia focal, y PC es una constante del estenopo. Según Jay Bender, su valor es 0.04, mientras que Kenneth Connors ha determinado que es 0.037. Otros han propuesto otros valores. Además, este valor es solo para la luz del día: otros tipos de luz requerirán una constante diferente, cuyo valor solo puede determinarse experimentalmente.

13.2.4. El número f

El número f es una medida muy útil de cuánta luz llega a la película. Un fotómetro puede determinar que, por ejemplo, exponer una película de una sensibilidad específica con f/5.6 puede requerir que la exposición dure 1/1000 seg.

No importa si es una cámara de 35 mm, o una cámara de 6x9cm, etc. Mientras conozcamos el número f, podemos determinar la exposición adecuada.

El número f es fácil de calcular:

F = FL / D

En otras palabras, el número f es igual a la distancia focal dividida por el diámetro del estenopo. También significa que un número f más alto implica un estenopo más pequeño o una distancia focal mayor, o ambas cosas. Eso, a su vez, implica que cuanto más alto es el número f, más larga tiene que ser la exposición.

Además, mientras que el diámetro del estenopo y la distancia focal son mediciones unidimensionales, tanto la película como el estenopo son bidimensionales. Eso significa que si ha medido la exposición al número f A como t, entonces la exposición al número f B es:

t * (B / A)²

13.2.5. Número f normalizado

Aunque muchas cámaras modernas pueden cambiar el diámetro de su estenopo, y por tanto su número f, de forma bastante suave y gradual, no siempre fue así.

Para permitir diferentes números f, las cámaras normalmente contenían una placa metálica con varios orificios de diferentes tamaños perforados en ella.

Sus tamaños se elegían según la fórmula anterior de tal forma que el número f resultante fuera uno de los números f estándar usados en todas las cámaras de todas partes. Por ejemplo, una cámara Kodak Duaflex IV muy antigua que poseo tiene tres de esos orificios para los números f 8, 11, y 16.

Una cámara de fabricación más reciente puede ofrecer números f de 2.8, 4, 5.6, 8, 11, 16, 22, y 32 (así como otros).

13.2.6. El diafragma (f-stop)

Una cámara típica está diseñada de tal forma que fijar cualquiera de los números f normalizados cambia la sensación del dial. Se detendrá de forma natural en esa posición. Debido a eso, estas posiciones del dial se llaman f-stops.

Como los números f en cada stop son potencias de la raíz cuadrada de 2, mover el dial 1 stop duplicará la cantidad de luz necesaria para una exposición adecuada. Moverlo 2 stops cuadruplicará la exposición necesaria. Mover el dial 3 stops requerirá aumentar la exposición 8 veces, etc.

13.3. Diseñar el software del estenopo

Ya estamos listos para decidir qué queremos exactamente que haga nuestro software del estenopo.

13.3.1. Procesar la entrada del programa

Como su propósito principal es ayudarnos a diseñar una cámara estenopeica que funcione, usaremos la distancia focal como entrada del programa. Esto es algo que podemos determinar sin software: la distancia focal adecuada viene determinada por el tamaño de la película y por la necesidad de tomar fotos "normales", fotos de gran angular, o fotos de teleobjetivo.

La mayoría de los programas que hemos escrito hasta ahora trabajaban con caracteres individuales, o bytes, como entrada: el programa hex convertía bytes individuales en un número hexadecimal, el programa csv o bien dejaba pasar un carácter, o lo eliminaba, o lo cambiaba por un carácter diferente, etc.

Un programa, ftuc, usaba la máquina de estados para considerar como mucho dos bytes de entrada a la vez.

Pero nuestro programa del estenopo no puede trabajar solo con caracteres individuales, tiene que lidiar con unidades sintácticas mayores.

Por ejemplo, si queremos que el programa calcule el diámetro del estenopo (y otros valores que comentaremos más adelante) para las distancias focales de 100 mm, 150 mm, y 210 mm, podríamos querer introducir algo así:

 100, 150, 210

Nuestro programa necesita considerar más de un solo byte de entrada a la vez. Cuando ve el primer 1, debe entender que está viendo el primer dígito de un número decimal. Cuando ve el 0 y el otro 0, debe saber que está viendo más dígitos del mismo número.

Cuando encuentra la primera coma, debe saber que ya no está recibiendo los dígitos del primer número. Debe ser capaz de convertir los dígitos del primer número en el valor 100. Y los dígitos del segundo número en el valor 150. Y, por supuesto, los dígitos del tercer número en el valor numérico 210.

Necesitamos decidir qué delimitadores aceptar: ¿tienen que estar separados por una coma los números de entrada? Si es así, ¿cómo tratamos dos números separados por otra cosa?

Personalmente, me gusta mantenerlo simple. Algo o bien es un número, así que lo proceso. O no es un número, así que lo descarto. No me gusta que el ordenador se queje de que yo teclee un carácter de más cuando es obvio que es un carácter de más. ¡Pues claro!

Además, me permite romper la monotonía de la informática y teclear una consulta en lugar de solo un número:

What is the best pinhole diameter for the
	    focal length of 150?

No hay razón para que el ordenador suelte una serie de quejas:

Syntax error: What
Syntax error: is
Syntax error: the
Syntax error: best

Etcétera, etcétera, etcétera.

En segundo lugar, me gusta que el carácter # denote el comienzo de un comentario que se extiende hasta el final de la línea. Esto no requiere demasiado esfuerzo para programarlo, y me permite tratar los archivos de entrada de mi software como scripts ejecutables.

En nuestro caso, también necesitamos decidir en qué unidades debe venir la entrada: elegimos los milímetros porque así es como la mayoría de los fotógrafos miden la distancia focal.

Finalmente, necesitamos decidir si permitir el uso del punto decimal (en cuyo caso también debemos considerar el hecho de que gran parte del mundo usa una coma decimal).

En nuestro caso permitir el punto/coma decimal ofrecería una falsa sensación de precisión: hay poca o ninguna diferencia apreciable entre las distancias focales de 50 y 51, así que permitir al usuario introducir algo como 50.5 no es buena idea. Esta es mi opinión, eso sí, pero yo soy quien escribe este programa. Usted puede tomar otras decisiones en el suyo, por supuesto.

13.3.2. Ofrecer opciones

Lo más importante que necesitamos saber al construir una cámara estenopeica es el diámetro del estenopo. Como queremos tomar imágenes nítidas, usaremos la fórmula anterior para calcular el diámetro del estenopo a partir de la distancia focal. Como los expertos ofrecen varios valores diferentes para la constante PC, necesitaremos poder elegir.

Es tradicional en la programación de UNIX tener dos formas principales de elegir los parámetros del programa, además de tener un valor por defecto para cuando el usuario no hace ninguna elección.

¿Por qué tener dos formas de elegir?

Una es permitir una elección (relativamente) permanente que se aplica automáticamente cada vez que se ejecuta el software sin que tengamos que decirle una y otra vez qué queremos que haga.

Las elecciones permanentes pueden almacenarse en un archivo de configuración, que normalmente se encuentra en el directorio personal del usuario. El archivo suele tener el mismo nombre que la aplicación pero empieza con un punto. A menudo se añade "rc" al nombre del archivo. Así, el nuestro podría ser ~/.pinhole o ~/.pinholerc. (El ~/ significa el directorio personal del usuario actual.)

El archivo de configuración lo usan sobre todo los programas que tienen muchos parámetros configurables. Los que tienen solo uno (o unos pocos) a menudo usan un método diferente: Esperan encontrar el parámetro en una variable de entorno. En nuestro caso, podríamos mirar una variable de entorno llamada PINHOLE.

Normalmente, un programa usa uno u otro de los métodos anteriores. De lo contrario, si un archivo de configuración dijera una cosa, pero una variable de entorno otra, el programa podría confundirse (o simplemente volverse demasiado complicado).

Como solo necesitamos elegir un parámetro de ese tipo, optaremos por el segundo método y buscaremos en el entorno una variable llamada PINHOLE.

La otra forma nos permite tomar decisiones ad hoc: "Aunque normalmente quiero que uses 0.039, esta vez quiero 0.03872." En otras palabras, nos permite anular la elección permanente.

Este tipo de elección se suele hacer con parámetros de línea de comandos.

Finalmente, un programa siempre necesita un valor por defecto. El usuario puede no hacer ninguna elección. Quizá no sepa qué elegir. Quizá solo esté "echando un vistazo". Preferiblemente, el valor por defecto será el valor que la mayoría de los usuarios elegiría de todos modos. De esa forma no necesitan elegir. O, mejor dicho, pueden elegir el valor por defecto sin un esfuerzo adicional.

Dado este sistema, el programa puede encontrar opciones en conflicto, y manejarlas de esta manera:

  1. Si encuentra una elección ad hoc (p. ej., un parámetro de línea de comandos), debería aceptar esa elección. Debe ignorar cualquier elección permanente y cualquier valor por defecto.

  2. De lo contrario, si encuentra una opción permanente (p. ej., una variable de entorno), debería aceptarla, e ignorar el valor por defecto.

  3. De lo contrario, debería usar el valor por defecto.

También necesitamos decidir qué formato debería tener nuestra opción PC.

A primera vista, parece obvio usar el formato PINHOLE=0.04 para la variable de entorno, y -p0.04 para la línea de comandos.

Permitir eso es en realidad un riesgo de seguridad. La constante PC es un número muy pequeño. Naturalmente, probaremos nuestro software usando varios valores pequeños de PC. Pero ¿qué ocurrirá si alguien ejecuta el programa eligiendo un valor enorme?

Puede hacer que el programa se cuelgue porque no lo hemos diseñado para manejar números enormes.

O, podemos dedicar más tiempo al programa para que pueda manejar números enormes. Podríamos hacer eso si estuviéramos escribiendo software comercial para un público analfabeto informático.

O, podríamos decir, "¡Pues peor para él! El usuario debería saberlo."

O, simplemente podemos hacer que al usuario le sea imposible introducir un número enorme. Este es el enfoque que adoptaremos: usaremos un prefijo 0. implícito.

En otras palabras, si el usuario quiere 0.04, esperaremos que teclee -p04, o establezca PINHOLE=04 en su entorno. Así, si dice -p9999999, lo interpretaremos como 0.9999999: sigue siendo ridículo pero al menos más seguro.

En segundo lugar, muchos usuarios simplemente querrán optar por la constante de Bender o la constante de Connors. Para facilitárselo, interpretaremos -b como idéntico a -p04, y -c como idéntico a -p037.

13.3.3. La salida

Necesitamos decidir qué queremos que nuestro software envíe a la salida, y en qué formato.

Como nuestra entrada permite un número no especificado de distancias focales, tiene sentido usar una salida tradicional de estilo base de datos que muestre el resultado del cálculo para cada distancia focal en una línea separada, separando todos los valores de una misma línea mediante un carácter tab.

Opcionalmente, también deberíamos permitir que el usuario especifique el uso del formato CSV que hemos estudiado antes. En este caso, imprimiremos una línea de nombres separados por comas que describan cada campo de cada línea, luego mostraremos nuestros resultados como antes, pero sustituyendo el tab por una coma.

Necesitamos una opción de línea de comandos para el formato CSV. No podemos usar -c porque eso ya significa usar la constante de Connors. Por alguna extraña razón, muchos sitios web se refieren a los archivos CSV como "hoja de cálculo de Excel" (aunque el formato CSV es anterior a Excel). Usaremos, por tanto, la opción -e para informar a nuestro software de que queremos la salida en el formato CSV.

Comenzaremos cada línea de la salida con la distancia focal. Esto puede sonar repetitivo al principio, especialmente en el modo interactivo: El usuario teclea la distancia focal, y nosotros la repetimos.

Pero el usuario puede teclear varias distancias focales en una línea. La entrada también puede provenir de un archivo o de la salida de otro programa. En ese caso el usuario no ve la entrada en absoluto.

Del mismo modo, la salida puede ir a un archivo que querremos examinar más tarde, o podría ir a la impresora, o convertirse en la entrada de otro programa.

Así que tiene todo el sentido comenzar cada línea con la distancia focal tal como la introdujo el usuario.

¡No, espere! No tal como la introdujo el usuario. ¿Qué pasa si el usuario teclea algo así:

 00000000150

Claramente, necesitamos eliminar esos ceros iniciales.

Así que podríamos considerar leer la entrada del usuario tal cual, convertirla a binario dentro de la FPU, e imprimirla desde ahí.

Pero…​

¿Qué pasa si el usuario teclea algo así:

 17459765723452353453534535353530530534563507309676764423

¡Ja! El formato decimal empaquetado de la FPU nos permite introducir números de 18 dígitos. Pero el usuario ha introducido más de 18 dígitos. ¿Cómo manejamos eso?

Bueno, podríamos modificar nuestro código para leer los primeros 18 dígitos, introducirlos en la FPU, luego leer más, multiplicar lo que ya tenemos en la TOS por 10 elevado al número de dígitos adicionales, y luego sumar a ello.

Sí, podríamos hacer eso. Pero en este programa sería ridículo (en otro distinto podría ser justo lo que hay que hacer): incluso la circunferencia de la Tierra expresada en milímetros solo ocupa 11 dígitos. Claramente, no podemos construir una cámara tan grande (al menos no todavía).

Así que, si el usuario introduce un número tan enorme, o bien está aburrido, o nos está poniendo a prueba, o intentando entrar en el sistema, o jugando: haciendo cualquier cosa menos diseñar una cámara estenopeica.

¿Qué haremos?

Le daremos una bofetada, por así decirlo:

17459765723452353453534535353530530534563507309676764423	???	???	???	???	???

Para lograr eso, simplemente ignoraremos cualquier cero inicial. Una vez que encontremos un dígito distinto de cero, inicializaremos un contador a 0 y comenzaremos a dar tres pasos:

  1. Enviar el dígito a la salida.

  2. Añadir el dígito a un búfer que usaremos más tarde para producir el decimal empaquetado que podemos enviar a la FPU.

  3. Incrementar el contador.

Ahora, mientras damos estos tres pasos, también necesitamos estar atentos a una de dos condiciones:

  • Si el contador crece por encima de 18, dejamos de añadir al búfer. Seguimos leyendo los dígitos y enviándolos a la salida.

  • Si, o más bien cuando, el siguiente carácter de entrada no sea un dígito, hemos terminado de introducir por ahora.

    Por cierto, podemos simplemente descartar el no-dígito, a menos que sea un #, que debemos devolver al flujo de entrada. Inicia un comentario, así que debemos verlo después de que hayamos terminado de producir salida y empecemos a buscar más entrada.

Eso todavía deja una posibilidad sin cubrir: si todo lo que el usuario introduce es un cero (o varios ceros), nunca encontraremos un número distinto de cero que mostrar.

Podemos determinar que esto ha ocurrido siempre que nuestro contador se quede en 0. En ese caso necesitamos enviar 0 a la salida, y dar otra "bofetada":

0	???	???	???	???	???

Una vez que hayamos mostrado la distancia focal y determinado que es válida (mayor que 0 pero sin exceder los 18 dígitos), podemos calcular el diámetro del estenopo.

No es por casualidad que pinhole contenga la palabra pin (alfiler). De hecho, muchos estenopos son literalmente un agujero de alfiler, un agujero cuidadosamente perforado con la punta de un alfiler.

Eso se debe a que un estenopo típico es muy pequeño. Nuestra fórmula obtiene el resultado en milímetros. Lo multiplicaremos por 1000, para poder mostrar el resultado en micras.

En este punto tenemos que enfrentarnos a otra trampa más: demasiada precisión.

Sí, la FPU fue diseñada para las matemáticas de alta precisión. Pero no estamos tratando con matemáticas de alta precisión. Estamos tratando con física (óptica, específicamente).

Supongamos que queremos convertir un camión en una cámara estenopeica (¡no seríamos los primeros en hacerlo!). Supongamos que su caja mide 12 metros de largo, así que tenemos una distancia focal de 12000. Bueno, usando la constante de Bender, nos da la raíz cuadrada de 12000 multiplicada por 0.04, que es 4.381780460 milímetros, o 4381.780460 micras.

Dicho de cualquier modo, el resultado es absurdamente preciso. Nuestro camión no mide exactamente 12000 milímetros de largo. No medimos su longitud con tal precisión, así que afirmar que necesitamos un estenopo con el diámetro de 4.381780460 milímetros es, bueno, engañoso. 4.4 milímetros servirían perfectamente.

"Solo" usé diez dígitos en el ejemplo anterior. ¡Imagine lo absurdo de usar los 18!

Necesitamos limitar el número de dígitos significativos de nuestro resultado. Una forma de hacerlo es usando un entero que represente micras. Así, nuestro camión necesitaría un estenopo con el diámetro de 4382 micras. Mirando ese número, decidimos igualmente que 4400 micras, o 4.4 milímetros, es lo bastante aproximado.

Además, podemos decidir que no importa cuán grande sea el resultado que obtengamos, solo queremos mostrar cuatro dígitos significativos (o cualquier otro número de ellos, por supuesto). Por desgracia, la FPU no ofrece redondeo a un número específico de dígitos (al fin y al cabo, no ve los números como decimales sino como binarios).

Por lo tanto, debemos idear un algoritmo para reducir el número de dígitos significativos.

Aquí está el mío (creo que es torpe; si conoce uno mejor, por favor, hágamelo saber):

  1. Inicializar un contador a 0.

  2. Mientras el número sea mayor o igual que 10000, dividirlo por 10 e incrementar el contador.

  3. Mostrar el resultado.

  4. Mientras el contador sea mayor que 0, mostrar 0 y decrementar el contador.

El 10000 solo sirve si quiere cuatro dígitos significativos. Para cualquier otro número de dígitos significativos, reemplace 10000 por 10 elevado al número de dígitos significativos.

Mostraremos, entonces, el diámetro del estenopo en micras, redondeado a cuatro dígitos significativos.

En este punto, conocemos la distancia focal y el diámetro del estenopo. Eso significa que tenemos suficiente información para calcular también el número f.

Mostraremos el número f, redondeado a cuatro dígitos significativos. Lo más probable es que el número f nos diga muy poco. Para hacerlo más significativo, podemos encontrar el número f normalizado más cercano, es decir, la potencia más cercana de la raíz cuadrada de 2.

Hacemos eso multiplicando el número f real por sí mismo, lo que, por supuesto, nos dará su cuadrado. Luego calcularemos su logaritmo en base 2, ¡que es mucho más fácil de hacer que calcular el logaritmo en base raíz cuadrada de 2! Redondearemos el resultado al entero más cercano. A continuación, elevaremos 2 al resultado. En realidad, la FPU nos da un buen atajo para hacer eso: podemos usar el código de operación fscale para "escalar" 1, lo que es análogo a hacer shift de un entero a la izquierda. Finalmente, calculamos la raíz cuadrada de todo ello, y tenemos el número f normalizado más cercano.

Si todo eso suena abrumador -o demasiado trabajo, quizá- puede quedar mucho más claro si ve el código. En total requiere 9 códigos de operación:

fmul	st0, st0
	fld1
	fld	st1
	fyl2x
	frndint
	fld1
	fscale
	fsqrt
	fstp	st1

La primera línea, fmul st0, st0, eleva al cuadrado el contenido de la TOS (la cima de la pila, igual que st, llamada st0 por nasm). El fld1 hace push de 1 en la TOS.

La siguiente línea, fld st1, hace push del cuadrado de vuelta a la TOS. En este punto el cuadrado está tanto en st como en st(2) (quedará claro en un momento por qué dejamos una segunda copia en la pila). st(1) contiene 1.

A continuación, fyl2x calcula el logaritmo en base 2 de st multiplicado por st(1). Por eso colocamos 1 en st(1) antes.

En este punto, st contiene el logaritmo que acabamos de calcular, st(1) contiene el cuadrado del número f real que guardamos para más tarde.

frndint redondea la TOS al entero más cercano. fld1 hace push de un 1. fscale desplaza el 1 que tenemos en la TOS según el valor de st(1), elevando efectivamente 2 a st(1).

Finalmente, fsqrt calcula la raíz cuadrada del resultado, es decir, el número f normalizado más cercano.

Ahora tenemos el número f normalizado más cercano en la TOS, el logaritmo en base 2 redondeado al entero más cercano en st(1), y el cuadrado del número f real en st(2). Estamos guardando el valor de st(2) para más tarde.

Pero ya no necesitamos el contenido de st(1). La última línea, fstp st1, coloca el contenido de st en st(1), y hace pop. Como resultado, lo que era st(1) ahora es st, lo que era st(2) ahora es st(1), etc. El nuevo st contiene el número f normalizado. El nuevo st(1) contiene el cuadrado del número f real que hemos almacenado ahí para la posteridad.

En este punto, estamos listos para mostrar el número f normalizado. Como está normalizado, no lo redondearemos a cuatro dígitos significativos, sino que lo mostraremos en su precisión completa.

El número f normalizado es útil mientras sea razonablemente pequeño y pueda encontrarse en nuestro fotómetro. De lo contrario necesitamos un método diferente para determinar la exposición adecuada.

Antes hemos deducido la fórmula para calcular la exposición adecuada a un número f arbitrario a partir de la medida a un número f diferente.

Todo fotómetro que he visto puede determinar la exposición adecuada a f5.6. Calcularemos, por tanto, un "multiplicador f5.6", es decir, por cuánto necesitamos multiplicar la exposición medida a f5.6 para determinar la exposición adecuada para nuestra cámara estenopeica.

A partir de la fórmula anterior sabemos que este factor puede calcularse dividiendo nuestro número f (el real, no el normalizado) por 5.6, y elevando al cuadrado el resultado.

Matemáticamente, dividir el cuadrado de nuestro número f por el cuadrado de 5.6 nos dará el mismo resultado.

Computacionalmente, no queremos elevar al cuadrado dos números cuando podemos elevar al cuadrado solo uno. Así que la primera solución parece mejor al principio.

Pero…​

5.6 es una constante. No tenemos por qué hacer que nuestra FPU desperdicie ciclos preciosos. Simplemente podemos decirle que divida el cuadrado del número f por lo que sea que valga 5.6². O podemos dividir el número f por 5.6, y luego elevar al cuadrado el resultado. Las dos formas parecen ahora iguales.

¡Pero no lo son!

Habiendo estudiado los principios de la fotografía más arriba, recordamos que el 5.6 es en realidad la raíz cuadrada de 2 elevada a la quinta potencia. Un número irracional. El cuadrado de este número es exactamente 32.

32 no solo es un entero, es una potencia de 2. No necesitamos dividir el cuadrado del número f por 32. Solo necesitamos usar fscale para desplazarlo cinco posiciones a la derecha. En la jerga de la FPU significa que le haremos fscale con st(1) igual a -5. Eso es mucho más rápido que una división.

Así que ahora ha quedado claro por qué hemos guardado el cuadrado del número f en la cima de la pila de la FPU. ¡El cálculo del multiplicador f5.6 es el cálculo más fácil de todo este programa! Lo mostraremos redondeado a cuatro dígitos significativos.

Hay un número útil más que podemos calcular: el número de stops a los que está nuestro número f de f5.6. Esto puede ayudarnos si nuestro número f está justo fuera del rango de nuestro fotómetro, pero tenemos un obturador que nos permite fijar diversas velocidades, y este obturador usa stops.

Digamos que nuestro número f está a 5 stops de f5.6, y el fotómetro dice que deberíamos usar 1/1000 seg. Entonces podemos fijar primero nuestra velocidad de obturación a 1/1000, luego mover el dial 5 stops.

Este cálculo es también bastante fácil. Todo lo que tenemos que hacer es calcular el logaritmo en base 2 del multiplicador f5.6 que acabábamos de calcular (aunque necesitamos su valor de antes de redondearlo). Luego mostramos el resultado redondeado al entero más cercano. No necesitamos preocuparnos por tener más de cuatro dígitos significativos en este: lo más probable es que el resultado tenga solo uno o dos dígitos de todos modos.

13.4. Optimizaciones de la FPU

En lenguaje ensamblador podemos optimizar el código de la FPU de formas imposibles en los lenguajes de alto nivel, incluido C.

Siempre que una función C necesita calcular un valor de coma flotante, carga todas las variables y constantes necesarias en los registros de la FPU. Luego hace el cálculo que sea necesario para obtener el resultado correcto. Los buenos compiladores de C pueden optimizar esa parte del código realmente bien.

"Devuelve" el valor dejando el resultado en la TOS. Sin embargo, antes de retornar, limpia. Cualesquiera variables y constantes que usó en su cálculo han desaparecido ahora de la FPU.

No puede hacer lo que acabamos de hacer arriba: calculamos el cuadrado del número f y lo mantuvimos en la pila para su uso posterior por otra función.

Sabíamos que necesitaríamos ese valor más adelante. También sabíamos que teníamos suficiente espacio en la pila (que solo tiene espacio para 8 números) para almacenarlo ahí.

Un compilador de C no tiene forma de saber que un valor que tiene en la pila volverá a requerirse en un futuro muy próximo.

Por supuesto, el programador de C puede saberlo. Pero el único recurso que tiene es almacenar el valor en una variable de memoria.

Eso significa, por un lado, que el valor se cambiará de la precisión de 80 bits usada internamente por la FPU a un double de C (64 bits) o incluso a un single (32 bits).

Eso también significa que el valor debe moverse de la TOS a la memoria, y luego de vuelta. Por desgracia, de todas las operaciones de la FPU, las que acceden a la memoria del ordenador son las más lentas.

Así que, siempre que programe la FPU en lenguaje ensamblador, busque las formas de mantener los resultados intermedios en la pila de la FPU.

¡Podemos llevar esa idea aún más lejos! En nuestro programa estamos usando una constante (la que llamamos PC).

No importa cuántos diámetros de estenopo estemos calculando: 1, 10, 20, 1000, siempre estamos usando la misma constante. Por lo tanto, podemos optimizar nuestro programa manteniendo la constante en la pila todo el tiempo.

Al principio de nuestro programa, estamos calculando el valor de la constante anterior. Necesitamos dividir nuestra entrada por 10 por cada dígito de la constante.

Es mucho más rápido multiplicar que dividir. Así que, al comienzo de nuestro programa, dividimos 1 entre 10 para obtener 0.1, que luego mantenemos en la pila: en lugar de dividir la entrada por 10 por cada dígito, la multiplicamos por 0.1.

Por cierto, no introducimos 0.1 directamente, aunque podríamos. Tenemos una razón para ello: aunque 0.1 puede expresarse con un solo decimal, no sabemos cuántas posiciones binarias ocupa. Por lo tanto, dejamos que la FPU calcule su valor binario con su propia alta precisión.

Estamos usando otras constantes: multiplicamos el diámetro del estenopo por 1000 para convertirlo de milímetros a micras. Comparamos números con 10000 cuando los redondeamos a cuatro dígitos significativos. Así que mantenemos ambos, 1000 y 10000, en la pila. Y, por supuesto, reutilizamos el 0.1 al redondear números a cuatro dígitos.

Por último, pero no menos importante, mantenemos -5 en la pila. Lo necesitamos para escalar el cuadrado del número f, en lugar de dividirlo por 32. No es por casualidad que carguemos esta constante en último lugar. Eso hace que sea la cima de la pila cuando solo las constantes están en ella. Así, cuando se está escalando el cuadrado del número f, el -5 está en st(1), precisamente donde fscale espera que esté.

Es común crear ciertas constantes desde cero en lugar de cargarlas desde la memoria. Eso es lo que estamos haciendo con -5:

	fld1			; TOS =  1
	fadd	st0, st0	; TOS =  2
	fadd	st0, st0	; TOS =  4
	fld1			; TOS =  1
	faddp	st1, st0	; TOS =  5
	fchs			; TOS = -5

Podemos generalizar todas estas optimizaciones en una sola regla: ¡mantenga los valores que se repiten en la pila!

PostScript® es un lenguaje de programación orientado a la pila. Hay muchos más libros disponibles sobre PostScript® que sobre el lenguaje ensamblador de la FPU: dominar PostScript® le ayudará a dominar la FPU.

13.5. pinhole: el código

;;;;;;; pinhole.asm ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;
; Find various parameters of a pinhole camera construction and use
;
; Started:	 9-Jun-2001
; Updated:	10-Jun-2001
;
; Copyright (c) 2001 G. Adam Stanislav
; All rights reserved.
;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

%include	'system.inc'

%define	BUFSIZE	2048

section	.data
align 4
ten	dd	10
thousand	dd	1000
tthou	dd	10000
fd.in	dd	stdin
fd.out	dd	stdout
envar	db	'PINHOLE='	; Exactly 8 bytes, or 2 dwords long
pinhole	db	'04,', 		; Bender's constant (0.04)
connors	db	'037', 0Ah	; Connors' constant
usg	db	'Usage: pinhole [-b] [-c] [-e] [-p <value>] [-o <outfile>] [-i <infile>]', 0Ah
usglen	equ	$-usg
iemsg	db	"pinhole: Can't open input file", 0Ah
iemlen	equ	$-iemsg
oemsg	db	"pinhole: Can't create output file", 0Ah
oemlen	equ	$-oemsg
pinmsg	db	"pinhole: The PINHOLE constant must not be 0", 0Ah
pinlen	equ	$-pinmsg
toobig	db	"pinhole: The PINHOLE constant may not exceed 18 decimal places", 0Ah
biglen	equ	$-toobig
huhmsg	db	9, '???'
separ	db	9, '???'
sep2	db	9, '???'
sep3	db	9, '???'
sep4	db	9, '???', 0Ah
huhlen	equ	$-huhmsg
header	db	'focal length in millimeters,pinhole diameter in microns,'
	db	'F-number,normalized F-number,F-5.6 multiplier,stops '
	db	'from F-5.6', 0Ah
headlen	equ	$-header

section .bss
ibuffer	resb	BUFSIZE
obuffer	resb	BUFSIZE
dbuffer	resb	20		; decimal input buffer
bbuffer	resb	10		; BCD buffer

section	.text
align 4
huh:
	call	write
	push	dword huhlen
	push	dword huhmsg
	push	dword [fd.out]
	sys.write
	add	esp, byte 12
	ret

align 4
perr:
	push	dword pinlen
	push	dword pinmsg
	push	dword stderr
	sys.write
	push	dword 4		; return failure
	sys.exit

align 4
consttoobig:
	push	dword biglen
	push	dword toobig
	push	dword stderr
	sys.write
	push	dword 5		; return failure
	sys.exit

align 4
ierr:
	push	dword iemlen
	push	dword iemsg
	push	dword stderr
	sys.write
	push	dword 1		; return failure
	sys.exit

align 4
oerr:
	push	dword oemlen
	push	dword oemsg
	push	dword stderr
	sys.write
	push	dword 2
	sys.exit

align 4
usage:
	push	dword usglen
	push	dword usg
	push	dword stderr
	sys.write
	push	dword 3
	sys.exit

align 4
global	_start
_start:
	add	esp, byte 8	; discard argc and argv[0]
	sub	esi, esi

.arg:
	pop	ecx
	or	ecx, ecx
	je	near .getenv		; no more arguments

	; ECX contains the pointer to an argument
	cmp	byte [ecx], '-'
	jne	usage

	inc	ecx
	mov	ax, [ecx]
	inc	ecx

.o:
	cmp	al, 'o'
	jne	.i

	; Make sure we are not asked for the output file twice
	cmp	dword [fd.out], stdout
	jne	usage

	; Find the path to output file - it is either at [ECX+1],
	; i.e., -ofile --
	; or in the next argument,
	; i.e., -o file

	or	ah, ah
	jne	.openoutput
	pop	ecx
	jecxz	usage

.openoutput:
	push	dword 420	; file mode (644 octal)
	push	dword 0200h | 0400h | 01h
	; O_CREAT | O_TRUNC | O_WRONLY
	push	ecx
	sys.open
	jc	near oerr

	add	esp, byte 12
	mov	[fd.out], eax
	jmp	short .arg

.i:
	cmp	al, 'i'
	jne	.p

	; Make sure we are not asked twice
	cmp	dword [fd.in], stdin
	jne	near usage

	; Find the path to the input file
	or	ah, ah
	jne	.openinput
	pop	ecx
	or	ecx, ecx
	je near usage

.openinput:
	push	dword 0		; O_RDONLY
	push	ecx
	sys.open
	jc	near ierr		; open failed

	add	esp, byte 8
	mov	[fd.in], eax
	jmp	.arg

.p:
	cmp	al, 'p'
	jne	.c
	or	ah, ah
	jne	.pcheck

	pop	ecx
	or	ecx, ecx
	je	near usage

	mov	ah, [ecx]

.pcheck:
	cmp	ah, '0'
	jl	near usage
	cmp	ah, '9'
	ja	near usage
	mov	esi, ecx
	jmp	.arg

.c:
	cmp	al, 'c'
	jne	.b
	or	ah, ah
	jne	near usage
	mov	esi, connors
	jmp	.arg

.b:
	cmp	al, 'b'
	jne	.e
	or	ah, ah
	jne	near usage
	mov	esi, pinhole
	jmp	.arg

.e:
	cmp	al, 'e'
	jne	near usage
	or	ah, ah
	jne	near usage
	mov	al, ','
	mov	[huhmsg], al
	mov	[separ], al
	mov	[sep2], al
	mov	[sep3], al
	mov	[sep4], al
	jmp	.arg

align 4
.getenv:
	; If ESI = 0, we did not have a -p argument,
	; and need to check the environment for "PINHOLE="
	or	esi, esi
	jne	.init

	sub	ecx, ecx

.nextenv:
	pop	esi
	or	esi, esi
	je	.default	; no PINHOLE envar found

	; check if this envar starts with 'PINHOLE='
	mov	edi, envar
	mov	cl, 2		; 'PINHOLE=' is 2 dwords long
rep	cmpsd
	jne	.nextenv

	; Check if it is followed by a digit
	mov	al, [esi]
	cmp	al, '0'
	jl	.default
	cmp	al, '9'
	jbe	.init
	; fall through

align 4
.default:
	; We got here because we had no -p argument,
	; and did not find the PINHOLE envar.
	mov	esi, pinhole
	; fall through

align 4
.init:
	sub	eax, eax
	sub	ebx, ebx
	sub	ecx, ecx
	sub	edx, edx
	mov	edi, dbuffer+1
	mov	byte [dbuffer], '0'

	; Convert the pinhole constant to real
.constloop:
	lodsb
	cmp	al, '9'
	ja	.setconst
	cmp	al, '0'
	je	.processconst
	jb	.setconst

	inc	dl

.processconst:
	inc	cl
	cmp	cl, 18
	ja	near consttoobig
	stosb
	jmp	short .constloop

align 4
.setconst:
	or	dl, dl
	je	near perr

	finit
	fild	dword [tthou]

	fld1
	fild	dword [ten]
	fdivp	st1, st0

	fild	dword [thousand]
	mov	edi, obuffer

	mov	ebp, ecx
	call	bcdload

.constdiv:
	fmul	st0, st2
	loop	.constdiv

	fld1
	fadd	st0, st0
	fadd	st0, st0
	fld1
	faddp	st1, st0
	fchs

	; If we are creating a CSV file,
	; print header
	cmp	byte [separ], ','
	jne	.bigloop

	push	dword headlen
	push	dword header
	push	dword [fd.out]
	sys.write

.bigloop:
	call	getchar
	jc	near done

	; Skip to the end of the line if you got '#'
	cmp	al, '#'
	jne	.num
	call	skiptoeol
	jmp	short .bigloop

.num:
	; See if you got a number
	cmp	al, '0'
	jl	.bigloop
	cmp	al, '9'
	ja	.bigloop

	; Yes, we have a number
	sub	ebp, ebp
	sub	edx, edx

.number:
	cmp	al, '0'
	je	.number0
	mov	dl, 1

.number0:
	or	dl, dl		; Skip leading 0's
	je	.nextnumber
	push	eax
	call	putchar
	pop	eax
	inc	ebp
	cmp	ebp, 19
	jae	.nextnumber
	mov	[dbuffer+ebp], al

.nextnumber:
	call	getchar
	jc	.work
	cmp	al, '#'
	je	.ungetc
	cmp	al, '0'
	jl	.work
	cmp	al, '9'
	ja	.work
	jmp	short .number

.ungetc:
	dec	esi
	inc	ebx

.work:
	; Now, do all the work
	or	dl, dl
	je	near .work0

	cmp	ebp, 19
	jae	near .toobig

	call	bcdload

	; Calculate pinhole diameter

	fld	st0	; save it
	fsqrt
	fmul	st0, st3
	fld	st0
	fmul	st5
	sub	ebp, ebp

	; Round off to 4 significant digits
.diameter:
	fcom	st0, st7
	fstsw	ax
	sahf
	jb	.printdiameter
	fmul	st0, st6
	inc	ebp
	jmp	short .diameter

.printdiameter:
	call	printnumber	; pinhole diameter

	; Calculate F-number

	fdivp	st1, st0
	fld	st0

	sub	ebp, ebp

.fnumber:
	fcom	st0, st6
	fstsw	ax
	sahf
	jb	.printfnumber
	fmul	st0, st5
	inc	ebp
	jmp	short .fnumber

.printfnumber:
	call	printnumber	; F number

	; Calculate normalized F-number
	fmul	st0, st0
	fld1
	fld	st1
	fyl2x
	frndint
	fld1
	fscale
	fsqrt
	fstp	st1

	sub	ebp, ebp
	call	printnumber

	; Calculate time multiplier from F-5.6

	fscale
	fld	st0

	; Round off to 4 significant digits
.fmul:
	fcom	st0, st6
	fstsw	ax
	sahf

	jb	.printfmul
	inc	ebp
	fmul	st0, st5
	jmp	short .fmul

.printfmul:
	call	printnumber	; F multiplier

	; Calculate F-stops from 5.6

	fld1
	fxch	st1
	fyl2x

	sub	ebp, ebp
	call	printnumber

	mov	al, 0Ah
	call	putchar
	jmp	.bigloop

.work0:
	mov	al, '0'
	call	putchar

align 4
.toobig:
	call	huh
	jmp	.bigloop

align 4
done:
	call	write		; flush output buffer

	; close files
	push	dword [fd.in]
	sys.close

	push	dword [fd.out]
	sys.close

	finit

	; return success
	push	dword 0
	sys.exit

align 4
skiptoeol:
	; Keep reading until you come to cr, lf, or eof
	call	getchar
	jc	done
	cmp	al, 0Ah
	jne	.cr
	ret

.cr:
	cmp	al, 0Dh
	jne	skiptoeol
	ret

align 4
getchar:
	or	ebx, ebx
	jne	.fetch

	call	read

.fetch:
	lodsb
	dec	ebx
	clc
	ret

read:
	jecxz	.read
	call	write

.read:
	push	dword BUFSIZE
	mov	esi, ibuffer
	push	esi
	push	dword [fd.in]
	sys.read
	add	esp, byte 12
	mov	ebx, eax
	or	eax, eax
	je	.empty
	sub	eax, eax
	ret

align 4
.empty:
	add	esp, byte 4
	stc
	ret

align 4
putchar:
	stosb
	inc	ecx
	cmp	ecx, BUFSIZE
	je	write
	ret

align 4
write:
	jecxz	.ret	; nothing to write
	sub	edi, ecx	; start of buffer
	push	ecx
	push	edi
	push	dword [fd.out]
	sys.write
	add	esp, byte 12
	sub	eax, eax
	sub	ecx, ecx	; buffer is empty now
.ret:
	ret

align 4
bcdload:
	; EBP contains the number of chars in dbuffer
	push	ecx
	push	esi
	push	edi

	lea	ecx, [ebp+1]
	lea	esi, [dbuffer+ebp-1]
	shr	ecx, 1

	std

	mov	edi, bbuffer
	sub	eax, eax
	mov	[edi], eax
	mov	[edi+4], eax
	mov	[edi+2], ax

.loop:
	lodsw
	sub	ax, 3030h
	shl	al, 4
	or	al, ah
	mov	[edi], al
	inc	edi
	loop	.loop

	fbld	[bbuffer]

	cld
	pop	edi
	pop	esi
	pop	ecx
	sub	eax, eax
	ret

align 4
printnumber:
	push	ebp
	mov	al, [separ]
	call	putchar

	; Print the integer at the TOS
	mov	ebp, bbuffer+9
	fbstp	[bbuffer]

	; Check the sign
	mov	al, [ebp]
	dec	ebp
	or	al, al
	jns	.leading

	; We got a negative number (should never happen)
	mov	al, '-'
	call	putchar

.leading:
	; Skip leading zeros
	mov	al, [ebp]
	dec	ebp
	or	al, al
	jne	.first
	cmp	ebp, bbuffer
	jae	.leading

	; We are here because the result was 0.
	; Print '0' and return
	mov	al, '0'
	jmp	putchar

.first:
	; We have found the first non-zero.
	; But it is still packed
	test	al, 0F0h
	jz	.second
	push	eax
	shr	al, 4
	add	al, '0'
	call	putchar
	pop	eax
	and	al, 0Fh

.second:
	add	al, '0'
	call	putchar

.next:
	cmp	ebp, bbuffer
	jb	.done

	mov	al, [ebp]
	push	eax
	shr	al, 4
	add	al, '0'
	call	putchar
	pop	eax
	and	al, 0Fh
	add	al, '0'
	call	putchar

	dec	ebp
	jmp	short .next

.done:
	pop	ebp
	or	ebp, ebp
	je	.ret

.zeros:
	mov	al, '0'
	call	putchar
	dec	ebp
	jne	.zeros

.ret:
	ret

El código sigue el mismo formato que todos los demás filtros que hemos visto antes, con una sutil excepción:

Ya no estamos asumiendo que el final de la entrada implique el final de las cosas por hacer, algo que dábamos por sentado en los filtros orientados a caracteres.

Este filtro no procesa caracteres. Procesa un lenguaje (aunque uno muy sencillo, consistente solo en números).

Cuando no tenemos más entrada, puede significar una de dos cosas:

  • Hemos terminado y podemos salir. Esto es lo mismo que antes.

  • El último carácter que hemos leído era un dígito. Lo hemos almacenado al final de nuestro búfer de conversión de ASCII a coma flotante. Ahora necesitamos convertir el contenido de ese búfer en un número y escribir la última línea de nuestra salida.

Por esa razón, hemos modificado nuestras rutinas getchar y read para que retornen con el carry flag limpio siempre que estemos obteniendo otro carácter de la entrada, o con el carry flag establecido siempre que no haya más entrada.

¡Por supuesto, seguimos usando magia del lenguaje ensamblador para hacer eso! Eche un buen vistazo a getchar. Siempre retorna con el carry flag limpio.

Y, sin embargo, nuestro código principal se basa en el carry flag para saber cuándo salir, y funciona.

La magia está en read. Siempre que recibe más entrada del sistema, simplemente retorna a getchar, que obtiene un carácter del búfer de entrada, limpia el carry flag y retorna.

Pero cuando read no recibe más entrada del sistema, no retorna a getchar en absoluto. En su lugar, el código de operación add esp, byte 4 suma 4 a ESP, establece el carry flag, y retorna.

Entonces, ¿a dónde retorna? Siempre que un programa usa el código de operación call, el microprocesador hace push de la dirección de retorno, es decir, la almacena en la cima de la pila (no la pila de la FPU, la pila del sistema, que está en la memoria). Cuando un programa usa el código de operación ret, el microprocesador hace pop del valor de retorno de la pila, y salta a la dirección que estaba almacenada ahí.

Pero como sumamos 4 a ESP (que es el registro puntero de pila), hemos dado efectivamente al microprocesador un caso leve de amnesia: ya no recuerda que fue getchar quien hizo call a read.

Y como getchar nunca hizo push de nada antes de hacer call a read, la cima de la pila contiene ahora la dirección de retorno a lo que sea o quien sea que hizo call a getchar. En lo que a ese llamador respecta, él hizo call a getchar, ¡que hizo ret con el carry flag establecido!

Aparte de eso, la rutina bcdload se ve atrapada en medio de un conflicto liliputiense entre los Big-Endians y los Little-Endians.

Está convirtiendo la representación textual de un número en ese número: el texto está almacenado en el orden big-endian, pero el decimal empaquetado es little-endian.

Para resolver el conflicto, usamos el código de operación std al principio. Lo cancelamos con cld más tarde: es bastante importante que no hagamos call a nada que pueda depender de la configuración por defecto del indicador de dirección mientras std esté activo.

Todo lo demás en este código debería quedar bastante claro, siempre que haya leído el artículo entero que lo precede.

Es un ejemplo clásico del dicho de que programar requiere mucho pensamiento y solo un poco de codificación. Una vez que hemos pensado detenidamente cada pequeño detalle, el código casi se escribe solo.

13.6. Usar pinhole

Como hemos decidido hacer que el programa ignore cualquier entrada excepto los números (e incluso esos dentro de un comentario), podemos de hecho realizar consultas textuales. No tenemos que hacerlo, pero podemos.

En mi humilde opinión, formular una consulta textual, en lugar de tener que seguir una sintaxis muy estricta, hace que el software sea mucho más amigable para el usuario.

Supongamos que queremos construir una cámara estenopeica para usar la película de 4x5 pulgadas. La distancia focal estándar para esa película es de unos 150mm. Queremos afinar nuestra distancia focal para que el diámetro del estenopo sea un número tan redondo como sea posible. Supongamos también que estamos bastante cómodos con las cámaras pero algo intimidados por los ordenadores. En lugar de tener simplemente que teclear un montón de números, queremos hacer un par de preguntas.

Nuestra sesión podría tener este aspecto:

% pinhole

Computer,

What size pinhole do I need for the focal length of 150?
150	490	306	362	2930	12
Hmmm... How about 160?
160	506	316	362	3125	12
Let's make it 155, please.
155	498	311	362	3027	12
Ah, let's try 157...
157	501	313	362	3066	12
156?
156	500	312	362	3047	12
That's it! Perfect! Thank you very much!
^D

Hemos descubierto que mientras que para la distancia focal de 150, nuestro diámetro de estenopo debería ser de 490 micras, o 0,49 mm, si optamos por la distancia focal casi idéntica de 156 mm, podemos arreglárnoslas con un diámetro de estenopo de exactamente medio milímetro.

13.7. Creación de scripts

Como hemos elegido el carácter # para denotar el comienzo de un comentario, podemos tratar nuestro software pinhole como un lenguaje de scripting.

Probablemente haya visto scripts de shell que comienzan con:

#! /bin/sh

…​o…​

#!/bin/sh

…​porque el espacio en blanco después del #! es opcional.

Siempre que se le pide a UNIX que ejecute un archivo ejecutable que comienza con #!, asume que el archivo es un script. Añade el comando al resto de la primera línea del script, e intenta ejecutar eso.

Supongamos ahora que hemos instalado pinhole en /usr/local/bin/, podemos ahora escribir un script para calcular diversos diámetros de estenopo adecuados para diversas distancias focales de uso común con la película de 120.

El script podría tener un aspecto parecido a este:

#! /usr/local/bin/pinhole -b -i
# Find the best pinhole diameter
# for the 120 film

### Standard
80

### Wide angle
30, 40, 50, 60, 70

### Telephoto
100, 120, 140

Como la 120 es una película de tamaño medio, podemos llamar a este archivo medium.

Podemos establecer sus permisos de ejecución, y ejecutarlo como si fuera un programa:

% chmod 755 medium
% ./medium

UNIX interpretará ese último comando como:

% /usr/local/bin/pinhole -b -i ./medium

Ejecutará ese comando y mostrará:

80	358	224	256	1562	11
30	219	137	128	586	9
40	253	158	181	781	10
50	283	177	181	977	10
60	310	194	181	1172	10
70	335	209	181	1367	10
100	400	250	256	1953	11
120	438	274	256	2344	11
140	473	296	256	2734	11

Ahora, introduzcamos:

% ./medium -c

UNIX tratará eso como:

% /usr/local/bin/pinhole -b -i ./medium -c

Eso le da dos opciones en conflicto: -b y -c (usar la constante de Bender y usar la constante de Connors). Lo hemos programado de forma que las opciones posteriores anulen las anteriores: nuestro programa calculará todo usando la constante de Connors:

80	331	242	256	1826	11
30	203	148	128	685	9
40	234	171	181	913	10
50	262	191	181	1141	10
60	287	209	181	1370	10
70	310	226	256	1598	11
100	370	270	256	2283	11
120	405	296	256	2739	11
140	438	320	362	3196	12

Decidimos que, después de todo, queremos optar por la constante de Bender. Queremos guardar sus valores como un archivo separado por comas:

% ./medium -b -e > bender
% cat bender
focal length in millimeters,pinhole diameter in microns,F-number,normalized F-number,F-5.6 multiplier,stops from F-5.6
80,358,224,256,1562,11
30,219,137,128,586,9
40,253,158,181,781,10
50,283,177,181,977,10
60,310,194,181,1172,10
70,335,209,181,1367,10
100,400,250,256,1953,11
120,438,274,256,2344,11
140,473,296,256,2734,11
%

14. Advertencias

Los programadores de lenguaje ensamblador que "crecieron" bajo MS-DOS y Windows tienden a menudo a tomar atajos. Leer los códigos de escaneo del teclado y escribir directamente en la memoria de vídeo son dos ejemplos clásicos de prácticas que, bajo MS-DOS, no están mal vistas sino que se consideran lo correcto.

¿La razón? Tanto la BIOS del PC como MS-DOS son notoriamente lentos al realizar estas operaciones.

Puede sentirse tentado a continuar con prácticas similares en el entorno UNIX. Por ejemplo, he visto un sitio web que explica cómo acceder a los códigos de escaneo del teclado en un clon popular de UNIX.

¡Eso es generalmente una muy mala idea en un entorno UNIX! Permítame explicar por qué.

14.1. UNIX está protegido

Por un lado, puede que simplemente no sea posible. UNIX se ejecuta en modo protegido. Solo el kernel y los controladores de dispositivos tienen permitido acceder al hardware directamente. Quizá un clon concreto de UNIX le permita leer los códigos de escaneo del teclado, pero lo más probable es que un sistema operativo UNIX real no lo haga. E incluso si una versión le permite hacerlo, puede que la siguiente no, de modo que su software cuidadosamente elaborado puede convertirse en un dinosaurio de la noche a la mañana.

14.2. UNIX es una abstracción

Pero hay una razón mucho más importante para no intentar acceder al hardware directamente (a menos, por supuesto, que esté escribiendo un controlador de dispositivo), incluso en los sistemas similares a UNIX que le permiten hacerlo:

¡UNIX es una abstracción!

Hay una diferencia importante en la filosofía de diseño entre MS-DOS y UNIX. MS-DOS fue diseñado como un sistema monousuario. Se ejecuta en un ordenador con un teclado y una pantalla de vídeo conectados directamente a ese ordenador. Está casi garantizado que la entrada del usuario provenga de ese teclado. La salida de su programa prácticamente siempre acaba en esa pantalla.

Esto NUNCA está garantizado bajo UNIX. Es bastante común que un usuario de UNIX canalice y redirija la entrada y la salida de los programas:

% program1 | program2 | program3 > file1

Si usted ha escrito program2, su entrada no proviene del teclado sino de la salida de program1. De forma similar, su salida no va a la pantalla sino que se convierte en la entrada de program3 cuya salida, a su vez, va a file1.

¡Pero hay más! Incluso si se aseguró de que su entrada proviene del terminal, y su salida va al terminal, no hay garantía de que el terminal sea un PC: puede que no tenga su memoria de vídeo donde usted espera, ni que su teclado esté produciendo códigos de escaneo al estilo PC. Puede ser un Macintosh, o cualquier otro ordenador.

Ahora puede que esté negando con la cabeza: mi software está en lenguaje ensamblador de PC, ¿cómo puede ejecutarse en un Macintosh? Pero yo no dije que su software se estuviera ejecutando en un Macintosh, solo que su terminal puede ser un Macintosh.

Bajo UNIX, el terminal no tiene por qué estar conectado directamente al ordenador que ejecuta su software, puede incluso estar en otro continente, o, ya puestos, en otro planeta. Es perfectamente posible que un usuario de Macintosh en Australia se conecte a un sistema UNIX en Norteamérica (o en cualquier otro sitio) vía telnet. El software se ejecuta entonces en un ordenador, mientras que el terminal está en un ordenador diferente: ¡si intenta leer los códigos de escaneo, obtendrá la entrada equivocada!

Lo mismo vale para cualquier otro hardware: un archivo que está leyendo puede estar en un disco al que no tiene acceso directo. Una cámara de la que está leyendo imágenes puede estar en un transbordador espacial, conectada a usted vía satélites.

Por eso bajo UNIX nunca debe hacer ninguna suposición sobre de dónde vienen y a dónde van sus datos. Deje siempre que el sistema maneje el acceso físico al hardware.

Estas son advertencias, no reglas absolutas. Las excepciones son posibles. Por ejemplo, si un editor de texto ha determinado que se está ejecutando en una máquina local, puede que quiera leer los códigos de escaneo directamente para un mejor control. No menciono estas advertencias para decirle qué hacer o qué no hacer, solo para hacerle consciente de ciertos escollos que le esperan si acaba de llegar a UNIX desde MS-DOS. Por supuesto, las personas creativas a menudo rompen las reglas, y está bien siempre que sepan que las están rompiendo y por qué.

15. Agradecimientos

Este tutorial nunca habría sido posible sin la ayuda de muchos programadores experimentados de FreeBSD de Lista de correo de discusiones técnicas en FreeBSD, muchos de los cuales han respondido pacientemente a mis preguntas, y me han orientado en la dirección correcta en mis intentos de explorar el funcionamiento interno de la programación de sistemas UNIX en general y de FreeBSD en particular.

Thomas M. Sommers me abrió la puerta. Su página web How do I write "Hello, world" in FreeBSD assembler? fue mi primer encuentro con un ejemplo de programación en lenguaje ensamblador bajo FreeBSD.

Jake Burkholder ha mantenido la puerta abierta respondiendo de buena gana a todas mis preguntas y proporcionándome código fuente de ejemplo en lenguaje ensamblador.

Copyright ® 2000-2001 G. Adam Stanislav. Todos los derechos reservados.