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
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 “®”.
Tabla de contenidos
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:
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 filename3.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 |
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 |
¿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
readywrite.Cerrar
stdincuando abrimos un archivo de entrada,stdoutcuando abrimos un archivo de salida.Añadir opciones de línea de comandos, como
-iy-o, para poder listar los archivos de entrada y de salida en cualquier orden, o quizá leer destdiny 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.oPara 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 myfileEstaría bien tener un ftuc, es decir, un tuc rápido, y usarlo así:
% ftuc myfileEn 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 |
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:
retGran 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:
El editor de imágenes cargará nuestro filtro usando la función C
popen().Leerá la primera fila de píxeles de un bitmap o pixmap.
Escribirá la primera fila de píxeles en la tubería que lleva al
fd.inde nuestro filtro.Nuestro filtro leerá cada píxel de su entrada, lo convertirá en negativo, y lo escribirá en su búfer de salida.
Nuestro filtro llamará a
getcharpara obtener el siguiente píxel.getcharencontrará un búfer de entrada vacío, así que llamará aread.readllamará a la llamada al sistemaSYS_read.El kernel suspenderá nuestro filtro hasta que el editor de imágenes envíe más datos a la tubería.
El editor de imágenes leerá de la otra tubería, conectada al
fd.outde nuestro filtro, para poder establecer la primera fila de la imagen de salida antes de enviarnos la segunda fila de la entrada.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, 210Nuestro 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: bestEtcé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:
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.
De lo contrario, si encuentra una opción permanente (p. ej., una variable de entorno), debería aceptarla, e ignorar el valor por defecto.
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í:
00000000150Claramente, 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:
Enviar el dígito a la salida.
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.
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):
Inicializar un contador a
0.Mientras el número sea mayor o igual que
10000, dividirlo por10e incrementar el contador.Mostrar el resultado.
Mientras el contador sea mayor que
0, mostrar0y decrementar el contador.
El |
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
getcharyreadpara que retornen con elcarry flaglimpio siempre que estemos obteniendo otro carácter de la entrada, o con elcarry flagestablecido 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 elcarry flaglimpio.Y, sin embargo, nuestro código principal se basa en el
carry flagpara saber cuándo salir, y funciona.La magia está en
read. Siempre que recibe más entrada del sistema, simplemente retorna agetchar, que obtiene un carácter del búfer de entrada, limpia elcarry flagy retorna.Pero cuando
readno recibe más entrada del sistema, no retorna agetcharen absoluto. En su lugar, el código de operaciónadd esp, byte 4suma4aESP, establece elcarry flag, y retorna.Entonces, ¿a dónde retorna? Siempre que un programa usa el código de operación
call, el microprocesador hacepushde 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ónret, el microprocesador hacepopdel valor de retorno de la pila, y salta a la dirección que estaba almacenada ahí.Pero como sumamos
4aESP(que es el registro puntero de pila), hemos dado efectivamente al microprocesador un caso leve de amnesia: ya no recuerda que fuegetcharquien hizocallaread.Y como
getcharnunca hizopushde nada antes de hacercallaread, la cima de la pila contiene ahora la dirección de retorno a lo que sea o quien sea que hizocallagetchar. En lo que a ese llamador respecta, él hizocallagetchar, ¡que hizoretcon elcarry flagestablecido!
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!
^DHemos 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
% ./mediumUNIX interpretará ese último comando como:
% /usr/local/bin/pinhole -b -i ./mediumEjecutará 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 11Ahora, introduzcamos:
% ./medium -cUNIX tratará eso como:
% /usr/local/bin/pinhole -b -i ./medium -cEso 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 12Decidimos 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 > file1Si 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.