¿Era la corrupción de la memoria un problema común en programas grandes escritos en lenguaje ensamblador?

Jan 21 2021

Los errores de corrupción de memoria siempre han sido un problema común en los grandes proyectos y programas de C. Fue un problema en 4.3BSD en ese entonces, y sigue siendo un problema hoy. No importa cuán cuidadosamente esté escrito el programa, si es lo suficientemente grande, a menudo es posible descubrir otro error de lectura o escritura fuera de límites en el código.

Pero hubo un tiempo en que los programas grandes, incluidos los sistemas operativos, se escribían en ensamblador, no C. ¿Los errores de corrupción de memoria eran un problema común en los programas ensambladores grandes? ¿Y cómo se compara con los programas C?

Respuestas

53 Jean-FrançoisFabre Jan 21 2021 at 17:23

La codificación en ensamblaje es brutal.

Punteros pícaros

Los lenguajes ensambladores se basan aún más en punteros (a través de registros de direcciones), por lo que ni siquiera puede confiar en el compilador o las herramientas de análisis estático para advertirle sobre tales corrupciones de memoria / desbordamientos de búfer en lugar de C.

Por ejemplo, en C, un buen compilador puede emitir una advertencia allí:

 char x[10];
 x[20] = 'c';

Eso es limitado. Tan pronto como la matriz se convierte en un puntero, tales comprobaciones no se pueden realizar, pero eso es un comienzo.

En el ensamblaje, sin el tiempo de ejecución adecuado o las herramientas binarias de ejecución formal, no puede detectar tales errores.

Registros fraudulentos (principalmente de direcciones)

Otro factor agravante para el ensamblaje es que la preservación del registro y la convención de llamadas de rutina no son estándar ni están garantizadas.

Si se llama a una rutina y no guarda un registro en particular por error, vuelve a la persona que llama con un registro modificado (junto a los registros "scratch" que se sabe que se eliminan al salir), y la persona que llama no espera it, lo que lleva a leer / escribir en la dirección incorrecta. Por ejemplo, en código 68k:

    move.b  d0,(a3)+
    bsr  a_routine
    move.b  d0,(a3)+   ; memory corruption, a3 has changed unexpectedly
    ...

a_routine:
    movem.l a0-a2,-(a7)
    ; do stuff
    lea some_table(pc),a3    ; change a3 if some condition is met
    movem.l (a7)+,a0-a2   ; the routine forgot to save a3 !
    rts

El uso de una rutina escrita por otra persona que no utiliza las mismas convenciones para guardar registros puede provocar el mismo problema. Por lo general, guardo todos los registros antes de usar la rutina de otra persona.

Por otro lado, un compilador usa la pila o el paso de parámetros de registro estándar, maneja las variables locales usando la pila / otro dispositivo, preserva los registros si es necesario, y todo es coherente en todo el programa, garantizado por el compilador (a menos que haya errores, de curso)

Modos de direccionamiento no autorizados

Arreglé muchas violaciones de memoria en juegos antiguos de Amiga. Ejecutarlos en un entorno virtual con MMU activada a veces desencadena errores de lectura / escritura en direcciones falsas completas. La mayoría de las veces, esas lecturas / escrituras no tienen efecto porque las lecturas devuelven 0 y las escrituras se pierden, pero dependiendo de la configuración de la memoria puede tener consecuencias desagradables.

También hubo casos de errores de direccionamiento. Vi cosas como:

 move.l $40000,a0

en lugar de inmediato

 move.l #$40000,a0

en ese caso, el registro de direcciones contiene lo que hay $40000(probablemente basura) y no la $40000dirección. Esto conduce a daños catastróficos en la memoria en algunos casos. El juego generalmente termina haciendo la acción que no funcionó en otro lugar sin arreglar esto, por lo que el juego funciona correctamente la mayor parte del tiempo. Pero hay ocasiones en las que los juegos tuvieron que arreglarse adecuadamente para restaurar el comportamiento adecuado.

En C, engañar al valor de un puntero conduce a una advertencia.

(Renunciamos a un juego como "Wicked" que tenía más y más corrupción gráfica a medida que avanzabas en los niveles, pero también dependiendo de la forma en que pasabas los niveles y su orden ...)

Tamaños de datos falsos

En montaje, no hay tipos. Significa que si lo hago

move.w #$4000,d0           ; copy only 16 bits
move.l #1,(a0,d0.l)    ; indexed write on d1, long

el d0registro solo cambia la mitad de los datos. Puede ser lo que quería, tal vez no. Luego, si d0contiene cero en los 32-16 bits más significativos, el código hace lo que se espera; de lo contrario, agrega a0y d0(rango completo) y la escritura resultante está "en el bosque". Una solución es:

move.l #1,(a0,d0.w)    ; indexed write on d1, long

Pero entonces, si d0> $7FFFtambién hace algo mal, porque d0se considera negativo, entonces (no es el caso de d0.l). Entonces d0necesita extensión de señal o enmascaramiento ...

Esos errores de tamaño se pueden ver en un código C, por ejemplo, al asignar a una shortvariable (que trunca el resultado), pero incluso entonces, la mayoría de las veces obtiene un resultado incorrecto, no problemas fatales como el anterior (es decir: si no no mientas al compilador forzando conversiones de tipos incorrectos)

Los ensambladores no tienen tipos, pero los buenos ensambladores permiten usar estructuras ( STRUCTpalabra clave) que permiten elevar un poco el código al calcular automáticamente las compensaciones de la estructura. Pero una lectura de tamaño incorrecto puede ser catastrófica sin importar si está usando estructuras / compensaciones definidas o no

move.w  the_offset(a0),d0

en vez de

move.l  the_offset(a0),d0

no está marcado y le proporciona los datos incorrectos en formato d0. Asegúrese de beber suficiente café mientras codifica, o simplemente escriba documentación en su lugar ...

Alineación de datos no autorizados

El ensamblador generalmente advierte sobre código no alineado, pero no sobre punteros no alineados (porque los punteros no tienen tipo), que pueden desencadenar errores de bus.

Los lenguajes de alto nivel usan tipos y evitan la mayoría de esos errores realizando alineación / relleno (a menos que, una vez más, le mientan).

Sin embargo, puede escribir programas de ensamblaje con éxito. Utilizando una metodología estricta para el paso de parámetros / guardado de registros y tratando de cubrir el 100% de su código mediante pruebas y un depurador (simbólico o no, este sigue siendo el código que ha escrito). Eso no eliminará todos los errores potenciales, especialmente los causados ​​por datos de entrada incorrectos, pero ayudará.

24 jackbochsler Jan 22 2021 at 05:41

Pasé la mayor parte de mi carrera escribiendo ensamblador, solo, equipos pequeños y equipos grandes (Cray, SGI, Sun, Oracle). Trabajé en sistemas integrados, SO, VM y cargadores de arranque. La corrupción de la memoria rara vez o nunca fue un problema. Contratamos gente inteligente, y los que fracasaron fueron asignados a diferentes trabajos más apropiados para sus habilidades.

También probamos fanáticamente, tanto a nivel de unidad como a nivel de sistema. Teníamos pruebas automatizadas que se ejecutaban constantemente tanto en simuladores como en hardware real.

Cerca del final de mi carrera, me entrevisté con una empresa y le pregunté cómo hacían sus pruebas automatizadas. Su respuesta de "¡¿Qué?!?" era todo lo que necesitaba escuchar, terminé la entrevista.

19 RETRAC Jan 21 2021 at 23:10

Los simples errores estúpidos abundan en el montaje, por muy cuidadoso que sea. Resulta que incluso los compiladores estúpidos para lenguajes de alto nivel mal definidos (como C) restringen una amplia gama de posibles errores como semántica o sintácticamente inválidos. Un error con una sola pulsación de tecla extra u olvidada es mucho más probable que se niegue a compilar que a ensamblar. Las construcciones que puede expresar válidamente en ensamblador que simplemente no tienen ningún sentido porque lo está haciendo todo mal tienen menos probabilidades de traducirse en algo que se acepte como C. válido. Y dado que está operando en un nivel superior, está es más probable que entrecerre los ojos y diga "¿eh?" y reescribe el monstruo que acabas de escribir.

Por lo tanto, el desarrollo y la depuración de ensamblajes es, de hecho, dolorosamente implacable. Pero la mayoría de estos errores rompen las cosas con dificultad y aparecerían en el desarrollo y la depuración. Me arriesgaría a la suposición educada de que, si los desarrolladores siguen la misma arquitectura básica y las mismas buenas prácticas de desarrollo, el producto final debería ser igual de sólido. El tipo de errores que detecta un compilador puede detectarse con buenas prácticas de desarrollo, y el tipo de errores que los compiladores no detectan pueden detectarse o no con dichas prácticas. Sin embargo, llevará mucho más tiempo llegar al mismo nivel.

14 WalterMitty Jan 23 2021 at 02:48

Escribí el recolector de basura original para MDL, un lenguaje similar a Lisp, allá por 1971-72. Fue todo un desafío para mí en ese entonces. Fue escrito en MIDAS, un ensamblador para el PDP-10 que ejecuta ITS.

Evitar la corrupción de la memoria era el nombre del juego en ese proyecto. Todo el equipo temía que una demostración exitosa fallara y se quemara cuando se invocaba al recolector de basura. Y no tenía un plan de depuración realmente bueno para ese código. Hice más comprobaciones de escritorio que nunca antes o desde entonces. Cosas como asegurarse de que no haya errores en los postes de la cerca. Asegurándose de que cuando se mueva un grupo de vectores, el objetivo no contenga nada que no sea basura. Una y otra vez, probando mis suposiciones.

Nunca encontré ningún error en ese código, a excepción de los encontrados por la verificación de escritorio. Después de que salimos en vivo, ninguno apareció durante mi vigilancia.

Simplemente no soy tan inteligente como hace cincuenta años. Hoy no podría hacer nada parecido. Y los sistemas de hoy son miles de veces más grandes que el MDL.

7 Raffzahn Jan 22 2021 at 00:00

Los errores de corrupción de memoria siempre han sido un problema común en los programas C grandes [...] Pero hubo un momento en que los programas grandes, incluidos los sistemas operativos, se escribían en ensamblador, no C.

¿Sabes que hay otros idiomas que ya eran bastante comunes desde el principio? ¿Como COBOL, FORTRAN o PL / 1?

¿Fueron los errores de corrupción de memoria un problema común en los grandes programas de ensamblaje?

Esto depende, por supuesto, de múltiples factores, como

  • el ensamblador utilizado, ya que los diferentes programas ensambladores ofrecen diferentes niveles de soporte de programación.
  • estructura del programa, ya que los programas especialmente grandes se adhieren a una estructura comprobable
  • modularización e interfaces claras
  • el tipo de programa escrito, ya que no todas las tareas requieren tocar el puntero
  • estilo de mejores prácticas

Un buen ensamblador no solo se asegura de que los datos estén alineados, sino que también ofrece herramientas para manejar tipos de datos complejos, estructuras y similares de manera abstracta, lo que reduce la necesidad de calcular punteros "manualmente".

Un ensamblador utilizado para cualquier proyecto serio es, como siempre, un ensamblador de macros (* 1), por lo que es capaz de encapsular operaciones primitivas en instrucciones macro de nivel superior, lo que permite una programación más centrada en la aplicación y evita muchos errores de manejo de punteros (* 2).

Los tipos de programas también son bastante influyentes. Las aplicaciones generalmente constan de varios módulos, muchos de ellos se pueden escribir casi o completos sin (o solo controlado) el uso del puntero. Una vez más, el uso de herramientas proporcionadas por el ensamblador es clave para un código menos defectuoso.

Lo siguiente sería la mejor práctica, que va de la mano con muchas de las anteriores. Simplemente no escriba programas / módulos que necesiten múltiples registros base, que entreguen grandes cantidades de memoria en lugar de estructuras de solicitud dedicadas, etc.

Pero las mejores prácticas comienzan desde el principio y con cosas aparentemente simples. Solo tome el ejemplo de una CPU primitiva (lo siento) como la 6502 que tiene quizás un conjunto de tablas, todas ajustadas a los bordes de la página para el rendimiento. Al cargar la dirección de una de estas tablas en un puntero de página cero para acceso indexado, el uso de las herramientas que el ensamblador querría usar

     LDA   #<Table
     STA   Pointer

Hay bastantes programas que he visto en lugar de ir

     LDA   #0
     STA   Pointer

(o peor, si está en un 65C02)

     STZ   Pointer

La argumentación habitual es "Pero está alineado de todos modos". ¿Lo es? ¿Se puede garantizar eso para todas las iteraciones futuras? ¿Qué pasa con algún día en que el espacio de direcciones se reduzca y sea necesario trasladarlas a direcciones no alineadas? Se pueden esperar muchos errores excelentes (también conocidos como difíciles de encontrar).

Por tanto, las mejores prácticas nos llevan de nuevo a utilizar Assembler y todas las herramientas que ofrece.

No intentes jugar al Ensamblador en lugar del Ensamblador , deja que él haga su trabajo por ti.

Y luego está el tiempo de ejecución, algo que se aplica a todos los idiomas pero que a menudo se olvida. Además de cosas como la verificación de la pila o la verificación de los límites de los parámetros, una de las formas más efectivas de detectar errores de puntero es simplemente bloquear la primera y la última página de memoria contra escritura y lectura (* 3). No solo detecta el amado error de puntero nulo, sino también todos los números bajos positivos o negativos que a menudo son el resultado de alguna indexación anterior que salió mal. Claro, el tiempo de ejecución es siempre el último recurso, pero este es fácil.

Sobre todo, quizás la razón más relevante sea

  • ISA de la máquina

para reducir las posibilidades de corrupción de la memoria al reducir la necesidad de manejar con punteros.

Algunas estructuras de CPU simplemente requieren menos operaciones de puntero (directas) que otras. Existe una gran brecha entre las arquitecturas que incluyen operaciones de memoria a memoria frente a aquellas que no lo hacen, como las arquitecturas de carga / almacenamiento basadas en acumuladores. Inherentemente requieren el manejo de punteros para cualquier cosa más grande que un solo elemento (byte / palabra).

Por ejemplo, para transferir un campo, digamos el nombre de un cliente de la memoria, un / 360 usa una sola operación MVC con direcciones y longitud de transferencia generada por el ensamblador a partir de la definición de datos, mientras que una arquitectura de carga / almacenamiento, diseñada para manejar cada byte por separado, tiene que configurar punteros y longitudes en los registros y hacer un bucle alrededor de un solo elemento en movimiento.

Dado que tales operaciones son bastante comunes, el potencial resultante de errores también es común. O, de forma más generalizada, se puede decir que:

Los programas para procesadores CISC suelen ser menos propensos a errores que los escritos para máquinas RISC.

Por supuesto y como es habitual, todo se puede estropear con una mala programación.

¿Y cómo se compara con los programas C?

Más o menos igual, o mejor, C es el equivalente HLL de la CPU ISA más primitiva, por lo que cualquier cosa que ofrezca instrucciones de mayor nivel será mucho mejor.

C es inherentemente un lenguaje RISCy. Las operaciones proporcionadas se reducen al mínimo, lo que va con una capacidad mínima de verificación contra operaciones no deseadas. El uso de punteros sin marcar no solo es estándar, sino que también es necesario para muchas operaciones, lo que abre muchas posibilidades de corrupción de la memoria.

Tomemos en contraste un HLL como ADA, aquí es casi imposible crear un caos de punteros, a menos que sea intencional y explícitamente declarado como opción. Una buena parte de esto se debe (como antes con la ISA) debido a tipos de datos más altos y al manejo de los mismos de manera segura.


En lo que respecta a la experiencia, hice la mayor parte de mi vida profesional (> 30 años) en proyectos de ensamblaje, con como 80% Mainframe (/ 370) 20% Micros (en su mayoría 8080 / x86) - más privados mucho más :) Proyectos cubiertos de programación Mainframe tan grandes como 2+ millones de LOC (solo instrucciones) mientras que los microproyectos se mantuvieron alrededor de 10-20k LOC.


* 1 - No, algo que ofrece la posibilidad de reemplazar pasajes de texto con texto prefabricado es, en el mejor de los casos, un preprocesador textual, pero no un ensamblador de macros. Un ensamblador de macros es una metaherramienta para crear el lenguaje necesario para un proyecto. Ofrece herramientas para aprovechar la información que el ensamblador recopila sobre la fuente (tamaño del campo, tipo de campo y muchos más), así como estructuras de control para formular el manejo, que se utilizan para generar el código apropiado.

* 2 - Es fácil lamentarse de que C no encajaba con ninguna capacidad macro seria, no solo eliminaría la necesidad de muchas construcciones oscuras, sino que también permitiría muchos avances al extender el lenguaje sin la necesidad de escribir uno nuevo.

* 3 - Personalmente prefiero hacer que la página 0 solo esté protegida contra escritura y llenar los primeros 256 bytes con cero binario. De esa manera, todas las escrituras de puntero nulo (o bajo) aún resultan en un error de máquina, pero la lectura de un puntero nulo devuelve, según el tipo, un byte / media palabra / palabra / doble palabra que contiene cero, bueno, o una cadena nula :) Lo sé, es perezoso, pero hace la vida mucho más fácil si uno tiene que no cooperar con el código de otras personas. Además, la página restante se puede utilizar para valores constantes útiles como punteros a varias fuentes globales, cadenas de identificación, contenido de campo constante y tablas de traducción.

6 waltinator Jan 22 2021 at 09:17

He escrito modificaciones de SO en ensamblado en CDC G-21, Univac 1108, DECSystem-10, DECSystem-20, todos los sistemas de 36 bits, más 2 ensambladores IBM 1401.

Existía "corrupción de memoria", principalmente como una entrada en una lista de "Cosas que no se deben hacer".

En un Univac 1108 encontré un error de hardware en el que la primera búsqueda de media palabra (la dirección del controlador de interrupciones) después de una interrupción de hardware devolvía todos los 1, en lugar del contenido de la dirección. Fuera a la maleza, con interrupciones desactivadas, sin protección de memoria. Da vueltas y vueltas, donde se detiene nadie lo sabe.

5 Peter-ReinstateMonica Jan 22 2021 at 19:31

Estás comparando manzanas y peras. Los lenguajes de alto nivel se inventaron porque los programas alcanzaron un tamaño que era inmanejable con ensamblador. Ejemplo: "V1 tenía 4.501 líneas de código ensamblador para su kernel, inicialización y shell. De esas, 3.976 corresponden al kernel y 374 al shell". (De esta respuesta ).

La. V1. Cáscara. Tenido. 347. Líneas. De. Código.

El bash de hoy tiene tal vez 100,000 líneas de código (un wc sobre el repositorio produce 170k), sin contar las bibliotecas centrales como readline y localización. Los lenguajes de alto nivel se utilizan en parte por su portabilidad, pero también porque es prácticamente imposible escribir programas del tamaño actual en ensamblador. No solo es más propenso a errores, es casi imposible.

4 supercat Jan 22 2021 at 03:45

No creo que la corrupción de la memoria sea, en general, un problema mayor en el lenguaje ensamblador que en cualquier otro lenguaje que use operaciones de subíndice de matriz no verificadas, al comparar programas que realizan tareas similares. Si bien escribir código ensamblador correcto puede requerir atención a detalles más allá de los que serían relevantes en un lenguaje como C, algunos aspectos del lenguaje ensamblador son en realidad más seguros que C.En lenguaje ensamblador, si el código realiza una secuencia de cargas y almacena, un ensamblador lo hará. Producir instrucciones de carga y almacenamiento en el orden dado sin cuestionar si todas son necesarias. En C, por el contrario, si se invoca un compilador inteligente como clang con cualquier configuración de optimización que no sea -O0y se le da algo como:

extern char x[],y[];
int test(int index)
{
    y[0] = 1;
    if (x+2 == y+index)
        y[index] = 2;
    return y[0];
}

puede determinar que el valor de y[0]cuándo se returnejecuta la declaración siempre será 1 y, por lo tanto, no es necesario volver a cargar su valor después de escribir en y[index], aunque la única circunstancia definida en la que podría ocurrir la escritura en el índice sería si x[]tiene dos bytes, y[]sucede para seguirlo inmediatamente, y indexes cero, lo que implica que en y[0]realidad quedaría sosteniendo el número 2.

3 phyrfox Jan 23 2021 at 23:33

Assembler requiere un conocimiento más profundo del hardware que está utilizando que otros lenguajes como C o Java. Sin embargo, la verdad es que el ensamblador se ha utilizado en casi todo, desde los primeros automóviles computarizados, los primeros sistemas de videojuegos hasta la década de 1990, hasta los dispositivos de Internet de las cosas que usamos hoy.

Si bien C ofrecía seguridad de tipos, todavía no ofrecía otras medidas de seguridad como verificación de puntero nulo o matrices limitadas (al menos, no sin código adicional). Era bastante fácil escribir un programa que se colapsara y se quemara tan bien como cualquier programa ensamblador.

Decenas de miles de juegos de video fueron escritas en ensamblador, compos para escribir demostraciones pequeñas pero impresionante en sólo unos pocos kilobytes de código / datos de décadas, miles de coches siguen utilizando alguna forma de ensamblador de hoy, así como algunos menos conocidos sistemas operativos (por ejemplo, MenuetOS ). Es posible que tenga docenas o incluso cientos de cosas en su casa que fueron programadas en ensamblador que ni siquiera conoce.

El principal problema con la programación en ensamblador es que necesita planificar más vigorosamente que en un lenguaje como C.Es perfectamente posible escribir un programa con incluso 100k líneas de código en ensamblador sin un solo error, y también es posible escribir un programa con 20 líneas de código que tiene 5 errores.

No es la herramienta el problema, es el programador. Diría que la corrupción de la memoria era un problema común en la programación inicial en general. Esto no se limitó al ensamblador, sino también a C (que era conocido por perder memoria y acceder a rangos de memoria no válidos), C ++ y otros lenguajes en los que se podía acceder directamente a la memoria, incluso a BASIC (que tenía la capacidad de leer / escribir I / O puertos en la CPU).

Incluso con los lenguajes modernos que tienen salvaguardas, veremos errores de programación que bloquean los juegos. ¿Por qué? Porque no se ha tenido suficiente cuidado al diseñar la aplicación. La gestión de la memoria no ha desaparecido, se ha escondido en un rincón donde es más difícil de visualizar, causando todo tipo de estragos aleatorios en el código moderno.

Prácticamente todos los idiomas son susceptibles a varios tipos de corrupción de memoria si se usan incorrectamente. Hoy en día, el problema más común son las pérdidas de memoria, que son más fáciles que nunca de introducir accidentalmente debido a cierres y abstracciones.

Es injusto decir que el ensamblador era inherentemente más o menos corruptor de la memoria que otros lenguajes, simplemente tuvo una mala reputación debido a lo difícil que era escribir el código adecuado.

2 JohnDoty Jan 23 2021 at 02:12

Fue un problema muy común. El compilador FORTRAN de IBM para el 1130 tenía bastantes: los que recuerdo involucraban casos de sintaxis incorrecta que no se detectaron. Pasar a lenguajes de nivel superior cercano a la máquina obviamente no ayudó: los primeros sistemas Multics escritos en PL / I fallaban con frecuencia. Creo que la cultura y la técnica de la programación tuvieron más que ver con mejorar esta situación que el lenguaje.

2 JohnDallman Jan 24 2021 at 21:26

Hice algunos años de programación en ensamblador, seguidos de décadas de C. Los programas de ensamblador no parecían tener más errores de puntero que C, pero una razón importante para ello fue que la programación en ensamblador es un trabajo relativamente lento.

Los equipos en los que estaba querían probar su trabajo cada vez que escribían un incremento de funcionalidad, que normalmente era cada 10-20 instrucciones de ensamblador. En lenguajes de nivel superior, normalmente se prueba después de un número similar de líneas de código, que tienen mucha más funcionalidad. Eso se compensa con la seguridad de un HLL.

Assembler dejó de usarse para tareas de programación a gran escala porque daba menor productividad y porque generalmente no era portátil a otros tipos de computadora. En los últimos 25 años he escrito alrededor de 8 líneas de ensamblador, y eso fue para generar condiciones de error para probar un controlador de errores.

1 postasaguest Jan 22 2021 at 23:25

No cuando trabajaba con computadoras en ese entonces. Tuvimos muchos problemas, pero nunca encontré problemas de corrupción de memoria.

Ahora trabajé en varias máquinas IBM 7090,360,370, s / 3, s / 7 y también micros basados ​​en 8080 y Z80. Es posible que otras computadoras hayan tenido problemas de memoria.