Leyendo archivo línea por línea en ensamblador – MASM32 – Parte 1

El manejo de archivos de texto a bajo nivel, en Windows, resulta problemático si no se tiene unas buenas librerías porque, a pesar de que los archivos de texto son muy comunes, no existe una función apropiada dentro de las API que nos permita leer líneas de texto directamente.

De todo el cargamento de funciones que nos ofrecen las API de Windows para el manejo de archivos, solo la función ReadFile(), y otras similares, nos permite leer datos directamente del disco.

Pero ReadFile() solo realiza lecturas por bloques de tamaño máximo, y no sabe de saltos de línea o delimitadores, así que en principio no nos sirve para leer línea por línea.

En este artículo, presentaré un método que nos permitirá leer efectivamente línea por línea desde un archivo de texto, usando ReadFile(), sin necesidad de tener que cargar todo el contenido del archivo en memoria.

El primer acercamiento para leer archivos de texto con ReadFile(), sería este código que lee solo bloque por bloque:

include \masm32\include\masm32rt.inc
.data
filePath    db "texto.txt", 0
buffer      db 100 dup(?)
hFile       HANDLE ?
bytesRead   DWORD ?

.code
start:
    ; Abrir el archivo
    invoke CreateFile, addr filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
    mov hFile, eax ; guardar el identificador de archivo

    ; Leer el archivo línea por línea
read_loop:
    invoke ReadFile, hFile, addr buffer, SIZEOF buffer, addr bytesRead, NULL
    cmp bytesRead, 0
    je close_file

    ;invoke StdOut, addr buffer
    invoke MessageBox, NULL, ADDR buffer, NULL, NULL

    jmp read_loop
    
close_file:     ; Cerrar el archivo
    invoke CloseHandle, hFile
    invoke ExitProcess, 0
end start

Este es el esquema típico de lectura de archivos usando ReadFile(). Si ensamblamos y ejecutamos este código con MASM32, nos mostrará por pantalla, el archivo «texto.txt» en bloques de 100 bytes cada uno.

Para mostrar mensajes en pantalla, usaremos la API MessageBox().

La función ReadFile() lee bloques de tamaño máximo «SIZEOF buffer» (que en nuestro caso es 100) y deposita los datos en «buffer».

Se podría aumentar o disminuir el tamaño de «buffer» sin ningún problema, pero hay que considerar que:

  • Un tamaño muy grande podría desperdiciar espacio si leemos archivos muy pequeños.
  • Un tamaño muy pequeño, requeriría hacer muchas lecturas en archivos grandes y afectaría el desempeño.

En este programa estaremos usando ReadFile() en modo síncrono y estaremos verificando la variable «bytesRead» para determinar que hemos llegado al final del archivo (cuando vale 0 después de una lectura).

El código anterior, aunque funciona, tiene muchos problemas, además de que lee bloque por bloque, en lugar de línea por línea. Un primer problema importante es que no existe un delimitador de cadena al final del bloque sino que se está mostrando todo el bloque completo, inclusive el último que, por lo general, queda incompleto.

También sería útil guardar en una variable global la condición de fin de archivo y crear procedimientos separados para la apertura y lectura del archivo.

El siguiente código incluye las mejoras comentadas:

include \masm32\include\masm32rt.inc
.const
    LINE_SIZE = 100; longitud máxima de una línea
.data
    filePath    db "input.tit", 0
    hFile       HANDLE ?        ;Manejador de archivo 
    buffer      db LINE_SIZE dup(?), 0    ;Bolsa de almacenamiento
    bytesRead   DWORD ?         ;Número de bytes leídos
    f_eof       db 0            ;Bandera de fin de archivo
.code

;Procedimiento para abrir un archivo. Actualiza "hFile" y "f_eof".
open_file PROC  
    invoke CreateFile, addr filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
    mov hFile, eax  ; Guardar el identificador de archivo
    mov f_eof, 0      ; Inicializa bandera de fin de archivo
    ret
open_file ENDP

;Procedimiento para leer un bloque de datos del archivo "hfile". Actualiza "f_eof".
read_block PROC
    invoke ReadFile, hFile, addr buffer, LINE_SIZE, addr bytesRead, NULL
    cmp bytesRead, 0
    jne no_file_end
    mov f_eof, 1      ;Marca bandera
no_file_end:
    ;Escribe caracter nulo al final de la cadena leída
    lea esi, buffer
    add esi, bytesRead
    mov byte ptr [esi], 0
    ret
read_block ENDP

    ; Programa principal
start:
    invoke open_file    ;Abrir el archivo
    ; Leer el archivo línea por línea
read_loop:
    invoke read_block   ;Lee bloque
    ;Verifica fin de archivo
    cmp f_eof, 1      ;¿Es fin de archivo?
    je close_file
    ;Muesta por pantalla
    invoke MessageBox, NULL, ADDR buffer, NULL, NULL
    jmp read_loop   ;Lee otra vez
close_file:     ; Cerrar el archivo
    invoke CloseHandle, hFile
    invoke ExitProcess, 0
end start

Con este código hemos mejorado la lectura del archivo, incluyendo un carácter NULO al final de los bytes leídos para que la cadena se muestre correctamente, incluyendo el último bloque. Para prevenir el poder alojar siempre el carácter nulo, se ha dejado un byte adicional al final de «buffer».

Pero aún no estamos leyendo línea por línea sino que seguimos leyendo bloque por bloque.

La lectura línea por línea, usando un «buffer» de tamaño fijo, resulta algo complicado, por el hecho de tener que reconocer los diversos saltos de línea que existen (CR, LF o CR-LF), además de tener que lidiar con la lectura parcial de líneas, cuando se usa ReadFile().

El siguiente código ofrece un método para leer un archivo de texto línea por línea, usando un único bloque de tamaño fijo en memoria.

Este programa puede reconocer correctamente los tres casos de delimitadores: CR, LF o CR-LF.

Sin embargo, existe un problema, que aunque poco probable, podría darse (No olvidar a Murphy):

PROBLEMA: Si la cadena tiene un salto de línea de dos bytes, tipo CR-LF, y si este salto de línea queda entre dos bloques consecutivos; al momento de la lectura, se interpretará como dos saltos de línea, haciendo que la rutina lea una línea vacía adicional.

Resolver este «bug», se deja como tarea. Pero como ayuda, se ha dejado un comentario en el código para identificar el punto donde debe solucionarse (en mi opinión, claro).

Por lo demás, y aunque no he hecho pruebas exhaustivas, el código parece funcionar bien. Si descubren algún error, me gustaría que lo comentaran.

Adicionalmente, existe una limitación:

LIMITACIÓN: El tamaño del "buffer" usado para la lectura, define también el tamaño máximo de la línea que se puede leer.

Esta limitación tiene sentido porque todas las líneas leídas deben almacenarse en algún lado y el tamaño del contenedor define el tamaño máximo de lo que puede contener.

Si se leen líneas que sean más largas que «buffer», se leerán como dos o más líneas recortadas.

Ya yendo al grano, el código en cuestión es este:

include \masm32\include\masm32rt.inc
.const
    LINE_SIZE = 100         ;Longitud máxima de una línea
.data
    filePath   db "input.tit", 0
    hFile      HANDLE ?     ;Manejador de archivo 
    buffer     db LINE_SIZE dup(?), 0    ;Bolsa de almacenamiento
    bytesRead  dword ?      ;Número de bytes leídos
    f_eof      db 0         ;Bandera de fin de archivo
    p_eol      dword ?      ;Puntero a fin de cadena en "buffer".
    c_eol      db ?         ;Caracter EOL encontrado. 
.code
;Proc. que posiciona "p_eol" al final de la línea que se encuentra 
;actualmente en "buffer". El final de la línea se reconoce por el 
;delimitador 0Ah o 0Dh. Si no se encuentra un delimitador de línea
;sale dejando "p_eol" apuntando al final de buffer + 1.
;Actualiza "c_eol".
search_EOL PROC
    mov edi, offset buffer
    add edi, bytesRead
    mov p_eol, offset buffer ;Inicio de buffer
    .WHILE  p_eol<edi
        mov esi, p_eol
        .IF byte ptr [esi] == 0Ah ;¿Salto de línea LF?
            mov c_eol, 0Ah  ;Guarda caracter
            .BREAK 
        .ENDIF
        .IF byte ptr [esi] == 0Dh ;¿Salto de línea CR?
            mov c_eol, 0Dh  ;Guarda caracter
            .BREAK 
        .ENDIF
        inc p_eol     ;Siguiente byte
    .ENDW
    ;Marca fin de línea para que se muestre solo esa línea.
    mov esi, p_eol
    mov byte ptr [esi], 0h
    ret
search_EOL ENDP
;Proc. para abrir un archivo. Actualiza "hFile" y "f_eof".
open_file PROC  
    invoke CreateFile, addr filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL
    mov hFile, eax  ; Guardar el identificador de archivo
    mov f_eof, 0      ; Inicializa bandera de fin de archivo
    ;La cadena a leer queda en "buffer"
    ;Inicia "p_eol" para forzar a "read_line" a hacer la primera lectura de disco.
    mov p_eol, offset buffer + LINE_SIZE
    ret
open_file ENDP
;Proc. para leer un bloque de datos del archivo "hfile". Actualiza 
;"f_eof".
read_block PROC
    ;Lectura desde disco
    invoke ReadFile, hFile, addr buffer, LINE_SIZE, addr bytesRead, NULL
    ;Actualiza bandera "f_eof".
    cmp bytesRead, 0
    jne no_file_end
    mov f_eof, 1      ;Marca bandera
  no_file_end:
    ;Escribe caracter nulo al final de la cadena leída
    mov edi, offset buffer
    add edi, bytesRead
    mov byte ptr [edi], 0
    ;Busca el fin de la primera línea (pueden haberse leído varias).
    invoke search_EOL
    ret
read_block ENDP
;Versión de "read_block" que lee en la parte superior de "buffer",
;a partir de la posición "bytesRead".
read_block2 PROC
    mov ebx, LINE_SIZE
    sub ebx, bytesRead
    mov edi, offset buffer
    add edi, bytesRead
    ;Salvamos "bytesRead".
    mov eax, bytesRead    
    mov p_eol, eax
    ;Lectura desde disco
    invoke ReadFile, hFile, edi, ebx, addr bytesRead, NULL
    ;Actualiza bandera "f_eof".
    cmp bytesRead, 0
    jne no_file_end
    mov f_eof, 1      ;Marca bandera
  no_file_end:
    ;Corrige "bytesRead"
    mov eax, bytesRead
    add eax, p_eol
    mov bytesRead, eax
    ;Escribe caracter nulo al final de la cadena leída
    mov edi, offset buffer
    add edi, bytesRead
    mov byte ptr [edi], 0
    ;Busca el fin de la primera línea (pueden haberse leído varias).
    invoke search_EOL   ;Actualiza "p_eol"
    ret
read_block2 ENDP
;Proc. que elimina de "buffer", por desplzamiento de bytes, la 
;primera línea que está delimitada por "p_eol".
del_line1 PROC
    ;Se supone que "p_eol" apunta al siguiente primer caracter delimitador 
    ;de fin de línea. Pero el delmitador puede ser de dos bytes.
    ;Primero vemos si el delimitador es CR-LF.
    mov esi, p_eol
    inc esi     ;Para verificar siguiente byte.
    .IF c_eol == 0Dh    ;Se encontró el caracter CR
        .IF byte ptr [esi] == 0Ah   ;Sigue un LF. Es un CR-LF.
            inc esi ;Apuntamos a siguiente byte para pasar por alto el LF.
        .ENDIF
    .ENDIF
    ;Calcula en "eax" el número de bytes a eliminar.
    sub esi, offset buffer
    mov eax, esi ; EAX <- p_eol - offset(buffer)
    ;Calcula número de bytes a desplazar en ECX
    mov ecx, bytesRead
    sub ecx, eax
    mov bytesRead, ecx  ;Aprovechamos para actualizar "bytesRead".
    inc ecx     ;Corregimos para considerar el chr(0) también.
    ;Realiza movimiento de bytes
    mov edi, offset buffer  ;Puntero a destino.
    mov esi, edi
    add esi, eax            ;Puntero a byte origen.
    cld         ;Dirección -> incrementando ESI/EDI
    rep movsb   ;Mueve "ecx" veces.
    ret
del_line1 ENDP
;Procedimiento para leer una línea de texto 
read_line PROC
    cmp f_eof, 1      ;¿Es fin de archivo?
    je exit_read
    ;Valida si se necesita leer de disco
    lea esi, buffer
    add esi, bytesRead  ;Ahora "esi" apunta al siguiente byte, 
                        ;despúes del bloque leído.
    .IF p_eol >= esi ;Terminamos de leer
        ;Puede que se trate de la primera lectura o una posterior.
        invoke read_block   ;Leemos bloque de texto.
        ;Si es que no se encuentra delimitador de línea en el bloque
        ;leído, se dejará "p_eol" apuntando al final del bloque+1, de
        ;modo que se considerará que todo el bloque es una línea y se
        ;dejará para la siguiente lectura leer la parte faltante de 
        ;la línea, si que existe.
    .ELSE       ;Aún hay datos que leer de buffer
        ;Eliminamos la primera línea de "buffer".
        invoke del_line1    ;Elimina bytes.
        ;Actualiza "p_eol" al fin de la siguiente línea.
        invoke search_EOL
        ;Verifica la línea identificada, para ver si está completa.
        lea esi, buffer
        add esi, bytesRead  ;Ahora "esi" apunta al siguiente byte, 
                            ;despúes del bloque leído.
 ;       .IF p_eol+1 == esi ;*** Este caso crítico habría que 
 ;                          ;verificarlo porque podría corresponder
 ;                          ;a un CR-LF que quedó entre dos bloques.
 ;       .ELSE
        .IF p_eol == esi    ;No se encontró delimitador en lo que queda del bloque.
            ;Por si hay más bloques por leer.
            invoke read_block2   ;Leemos bloque de texto.
            ;Aquí ya se actualizó "p_eol" y "bytesRead"
        .ENDIF
;        .ENDIF
    .ENDIF
  exit_read:
    ret
read_line ENDP
    ;------------ Programa principal ---------------
start:
    invoke open_file    ;Abrir el archivo
    ; Leer el archivo línea por línea
  read_loop:
    ;Verifica fin de archivo
    cmp f_eof, 1    ;¿Es fin de archivo?
    je close_file
    ;Lee bloque
    invoke read_line   
    ;Muesta por pantalla
    invoke MessageBox, NULL, offset buffer, NULL, NULL
    jmp read_loop   ;Lee otra vez
  close_file:       ;Cerrar el archivo
    invoke CloseHandle, hFile
    invoke ExitProcess, 0
end start

El método que usa este programa consiste en ir identificando las líneas dentro de «buffer» para ir marcándolas con un carácter nulo. Luego, en la siguiente lectura, si es que aún quedan líneas por leer dentro de «buffer», se elimina la línea anterior desplazando los bytes para que quede accesible la siguiente línea al inicio de «buffer».

Este proceso se repite hasta que ya no queden líneas por leer dentro de «buffer» o queden líneas incompletas. En este caso, se procede a hacer una nueva lectura con la API ReadFile() para completar la línea incompleta (subrutina «read_block2»).

Este programa es bastante rápido, pero siempre queda lugar para mejoras. No me he basado en ningún código previo, sino que he codificado directamente el método que he diseñado, sin ser un experto en la arquitectura Intel o el ensamblador MASM32.

Otra alternativa para simplificar el trabajo de lectura línea por línea podría ser leer todo el contenido del archivo en una zona de memoria dinámica, y esa solución sería bastante rápida pero podría ser perjudicial cuando se leen archivos muy grandes.

En la siguiente parte de este artículo exploraré esta otra alternativa.


Sé el primero en comentar

Dejar una contestacion

Tu dirección de correo electrónico no será publicada.


*