Llamadas a subrutinas en el mundo antiguo: antes de que las computadoras tuvieran stack o heap
(devblogs.microsoft.com)- Las primeras computadoras tenían que implementar llamadas a funciones sin stack ni heap, y el compilador administraba el estado de la llamada con variables globales ocultas que correspondían a parámetros, direcciones de retorno y variables locales
- Quien llamaba guardaba los argumentos, colocaba la posición de regreso en una variable de dirección de retorno y luego saltaba al punto de entrada de la función; la función, tras hacer el cálculo, volvía a saltar a la dirección guardada
- Incluso las variables locales lógicas usaban en realidad almacenamiento global, así que, aunque por fuera parecían funciones, por dentro se comportaban más como memoria fija y
goto - Algunos ABI y procesadores optimizaban el paso de argumentos y el manejo de la dirección de retorno con registros o
branch with link, pero la limitación básica seguía ahí - Como la dirección de retorno de una misma función era sobrescrita por una nueva llamada, la recursión era imposible, y los lenguajes de la época respondían prohibiéndola o permitiéndola solo de forma explícita
Cómo construir llamadas a funciones sin stack
- En los primeros entornos de cómputo no existían ni el stack ni el heap que hoy damos por sentados
- La asignación dinámica de memoria sin heap podía sustituirse por búferes de tamaño fijo
- Incluso al procesar datos de tamaño variable, se reservaba por adelantado un búfer fijo lo bastante grande
- Si los datos solicitados excedían la capacidad del búfer, el programa terminaba con un error fatal
- Una implementación más amable permitía configurar la capacidad máxima en tiempo de compilación
- Una implementación más sofisticada podía poner un asignador personalizado sobre el búfer fijo y usarlo como
allocateyfree
Convención de llamada basada en variables globales ocultas
- Para implementar llamadas a funciones sin stack, el compilador definía varias variables globales ocultas para cada función
- una variable global por cada parámetro de entrada
- una variable global para guardar la dirección de retorno de la función
- variables globales correspondientes a las variables locales
- El código de llamada se ejecutaba en el siguiente orden
- guardar los valores de los parámetros en sus variables globales ocultas correspondientes
- registrar la posición de regreso en la variable de dirección de retorno de la función
- hacer un salto
gotoal punto de inicio de la función
- La función leía y escribía tanto los parámetros como las variables locales desde esas variables globales ocultas
- Al terminar, colocaba el valor de retorno en el registro de valor de retorno y saltaba a la dirección almacenada en la variable de dirección de retorno de la función
Ejemplo de cómo un código parecido a C se transforma en código basado en goto
- La función de ejemplo
add_two_values(int a, int b)podía transformarse sin stack en espacios de almacenamiento como los siguientesa2v_aya2v_bson variables globales para guardar los argumentosa2v_ces la variable global correspondiente a la variable localca2v_retaddres la variable global para guardar la dirección de retorno
- Quien llama,
sample(), guarda31415y2718respectivamente en las variables globales de argumentos - Luego coloca la posición
resumeena2v_retaddry salta aadd_two_values add_two_valuesguarda el resultado del cálculo enreturn_value_registery luego regresa mediantea2v_retaddr- De vuelta en la posición
resume, quien llama guarda el valor del registro de retorno ensample_x
Optimización con registros y branch with link
- La misma estructura podía hacerse más rápida a nivel ABI mediante paso por registros
- Muchos procesadores ofrecían un
link registerespecial y la instrucciónbranch with linkbranch with linkguarda automáticamente en el link register la dirección de la instrucción siguiente a la instrucción de bifurcación- Quien llama puede poner los dos primeros argumentos en
argument_register_1yargument_register_2 - La función llamada puede mover los valores de esos registros a sus propias variables globales ocultas para usarlos
- La dirección de retorno también podía guardarse desde
link_registeren la variable de dirección de retorno de la función - Esta optimización mantenía la estructura básica de que las llamadas y los retornos podían hacerse sin stack
Por qué la recursión quedaba bloqueada
- La restricción central de este esquema de llamadas es la imposibilidad de recursión
- Si ocurría una llamada recursiva, la variable de dirección de retorno de esa misma función era sobrescrita con la dirección de retorno de la nueva llamada
- Cuando terminaba la llamada externa, la posición original a la que debía volver ya había desaparecido, y se terminaba saltando a un lugar incorrecto
- Los lenguajes de programación de la época evitaban este problema al no admitir recursión
- FORTRAN al principio ni siquiera admitía subrutinas, y estas se agregaron en 1958
- El soporte de recursión no se volvió estándar en FORTRAN hasta 1991, e incluso entonces había que declarar la subrutina como
RECURSIVE
Código automodificable y las instrucciones de subrutina en los primeros procesadores
- Algunos compiladores usaban de forma aún más ingeniosa código automodificable
- el campo de dirección dentro de la instrucción de salto al final de la función actuaba en la práctica como variable de dirección de retorno
- Este método no era solo un truco simple, sino que podía ser una necesidad práctica
- algunos procesadores podían no admitir saltos indirectos
- Una vez reconocida la utilidad práctica de las subrutinas, varios procesadores añadieron instrucciones dedicadas de llamada
- guardaban la dirección de retorno en la primera palabra de la subrutina
- la ejecución real comenzaba en la segunda palabra
- al regresar, ejecutaban un salto indirecto mediante la etiqueta de inicio de la subrutina
- En el ejemplo de ensamblador,
bsr add_two_valuesguarda la dirección de retorno en la primera palabra deadd_two_valuesy empieza a ejecutar desde la instrucción real después delnopde sacrificio
1 comentarios
Opiniones de Hacker News
En este tema, The Art of Computer Programming fue realmente muy bueno
A primera vista parece anticuado, pero contiene una enorme cantidad de algoritmos para manejar arreglos o estructuras de datos que cambian dinámicamente, de la época anterior al heap o al stack
El libro avanza paso a paso hasta la recolección de basura y la implementación de listas de Lisp, e incluye exactamente ese conocimiento enciclopédico que uno espera de Knuth
Un ejemplo que me gusta especialmente es la forma en que dos arreglos comparten dinámicamente un mismo espacio. Si un arreglo crece hacia adelante desde
location#0y el segundo crece hacia atrás desdelocation#End, se puede dividir eficientemente un espacio asignado de forma estáticaTambién se puede extender a un número arbitrario de arreglos, pero llegado a ese punto conviene más usar
MallocyRealloc, y la técnica en sí ya se parece bastante a una rutina tipo mallocInsertar y pegar no requería desplazar datos, pero moverse por el texto sí. Aun así, funcionaba bien
Si no había tanto disponible, se le asignaba menos que lo preferido; si ni siquiera podía obtener el mínimo, fallaba al ejecutarse
Recuerdo que el sistema colocaba el heap y las bibliotecas en la parte inferior de ese fragmento de RAM física, y el stack en la parte superior
Alrededor de System 8 se agregó una capa de virtualización, con lo que este enfoque se volvió menos necesario, y para la época de MacOS X, al usar memoria paginada como otros sistemas, estos trucos ya no hacían falta
Aun así, es divertido pensar en una época en la que este “truco raro” de Art of Computer Programming era la forma de asignar RAM a varias apps ejecutándose al mismo tiempo
Uno crecía hacia arriba y el otro hacia abajo. Era una estructura fascinante, pero al final no logró entregar el rendimiento prometido
Dentro de una página de tamaño fijo, el arreglo de offsets crece hacia adelante y el arreglo de valores de filas de longitud variable crece hacia atrás desde el final. Según entiendo, al eliminar filas pueden quedar huecos en el arreglo de atrás
Como la documentación cita a TAOCP para la propia estructura B-tree, no me sorprendería que hubiera sido una inspiración directa
Incorporar funciones recursivas en ALGOL fue bastante polémico y sigue siendo una historia interesante: https://vanemden.wordpress.com/2014/06/18/how-recursion-got-...
How recursion got into programming: intrigue, betrayal, and advanced semantics - https://news.ycombinator.com/item?id=33123916 - octubre de 2022, 8 comentarios
How Recursion Got into Programming (2014) - https://news.ycombinator.com/item?id=23061881 - mayo de 2020, 47 comentarios
How recursion got into Algol 60: a comedy of errors - https://news.ycombinator.com/item?id=10131664 - agosto de 2015, 124 comentarios
How recursion got into programming: a comedy of errors - https://news.ycombinator.com/item?id=8073361 - julio de 2014, 108 comentarios
Escribí un intérprete de Forth para una máquina SUBLEQ (https://github.com/howerj/subleq) y un intérprete para una máquina bit-serial (https://github.com/howerj/bit-serial); ninguno de los dos tenía la pila de llamadas a funciones que Forth necesita.
SUBLEQ tampoco permite cargas/almacenamientos indirectos, así que para hacer cualquier cosa mínimamente compleja se necesita código automodificable.
El enfoque fue crear en ambas máquinas una máquina virtual que pudiera realizar esas funciones, e incorporar también multithreading cooperativo.
Si hace falta un heap, se escribe en Forth, y el conjunto de palabras de punto flotante también se escribe en Forth. Muchos MCU todavía no tienen instrucciones de punto flotante, y eso puede manejarse con llamadas a funciones de software que lo implementen.
Aunque no se mencionen, supongo que otros compiladores usaron enfoques similares. Algunos intérpretes de BASIC también implementaban una VM y luego la usaban como destino, y P-Code es parecido.
La mayor parte de la memoria básica del sistema era RAM de video, y había que acceder a ella mediante un procedimiento bastante engorroso de
poke/peeka los registros del chip de video.El chip de video mantenía un puntero de memoria actual con autoincremento, de modo que en lecturas o escrituras consecutivas el puntero aumentaba de a 1, pero el solo hecho de que la mayor parte de la memoria del sistema solo fuera accesible de esta forma hacía difícil escribir programas grandes.
Por eso TI creó una máquina abstracta llamada GPL, que hacía más natural este acceso a la RAM de video. Sin embargo, al ejecutarse interpretada sobre el TMS9900, era más lenta que el código nativo, y además la CPU solo podía acceder a la RAM del chip de video en momentos en que el chip no estuviera haciendo el barrido de la pantalla, como durante el retorno horizontal/vertical, así que era todavía más lenta.
Como el código BASIC y las variables también estaban por completo en esta memoria de video, es bastante obvio en qué estaba escrito el intérprete de BASIC de la TI-99/4A. No era nada rápido.
Lo interesante es que el TMS9900 no tenía registros de propósito general reales. Los registros de espacio de trabajo WR0~WR15 estaban en algún lugar de la memoria, y el registro puntero de espacio de trabajo WP apuntaba a ellos.
Los únicos registros físicos de la CPU eran PC, WP y el registro de estado. En consecuencia, se podía hacer una forma muy primitiva de ventaneo de registros, y al bifurcar con la instrucción
BLWPse activaba un nuevo conjunto de “registros” en otra ubicación de memoria, mientras la dirección de retorno se guardaba en el nuevo espacio de trabajo.Últimamente hablo seguido de la TI-99/4A porque, como proyecto personal, estoy haciendo un ensamblador para esta máquina.
Es cierto que algunos procesadores guardaban la dirección de retorno en la palabra inmediatamente anterior a la primera instrucción de la subrutina, y el PDP-8 hacía eso.
La evolución del PDP-8 también puede verse como un viaje hacia el soporte de hardware para la recursión.
Al principio, la instrucción
JMSincrustaba la dirección de retorno en la primera palabra de la función. A menudo el llamador ponía los argumentos después de la instrucciónJMS, y el llamado leía los argumentos con offsets respecto de la instrucción de retorno, incrementándola cada vez para que la dirección de retorno volviera a apuntar a una ubicación de código.Más adelante se volvió bastante común crear una pila simple usando una de las ubicaciones de autoincremento. El PDP-8 tenía ocho ubicaciones de memoria que se incrementaban cada vez que se usaban como punteros, y el prólogo/epílogo de la función gestionaba directamente esta pila, lo que hacía posible la recursión completa.
Más tarde aún, implementaciones en microprocesador como el Harris 6120 agregaron una pila de hardware, lo que mejoró el rendimiento.
R, es decir, una instrucción para guardar la dirección de retorno.Esta instrucción guardaba el
PC+1ya incrementado en la parte de dirección de instrucción de la ubicación destino, y por convención ese destino era una instrucción de salto incondicional justo antes del inicio de la subrutina.Después de la instrucción
Rse ponía una instrucciónUde salto incondicional hacia la subrutina correspondiente.La subrutina retornaba saltando a la dirección que estaba justo antes de ella, donde había un salto incondicional de vuelta a lo que seguía inmediatamente al punto de llamada.
La recursión era imposible salvo que se usara una convención de llamada más avanzada. Y todos los códigos de instrucción del lenguaje ensamblador eran de una sola letra.
En programas escritos para AVR-8, usar la convención de llamada de C a veces se siente como una locura.
Si usas ensamblador, puedes mantener las variables del bucle interno dentro del gran archivo de registros, o puedes usar los métodos descritos en el artículo.
También me gusta la forma de “colorear” funciones en este tipo de apps. Si sabes que una función roja y una función verde no estarán activas al mismo tiempo, puedes reutilizar sus variables locales o parámetros.
En un proyecto de una base de código para microcontrolador al que me sumé hace tiempo, varios desarrolladores llevaban semanas rastreando bugs difíciles de atrapar en varios subsistemas.
Si movían el código, el bug también se movía con él. Tras investigar un poco y poner trampas, se pudieron encontrar puntos del código donde la pila de llamadas crecía demasiado y sobrescribía otras estructuras de datos.
Cuando aprendí a programar por primera vez, me obligaron a programar exactamente de esta manera. No fue en los años 70, sino en 2001
Porque mi primera experiencia de programación fue con el “lenguaje” de scripting semigráfico que ofrecía la herramienta de desarrollo de juegos RPG Maker 2000
Si nunca viste el scripting de RM2K, imagina una mezcla entre Scratch y el modo Paredit de Emacs. Ej.: https://forums.rpgmakerweb.com/data/attachments/21/21958-f89...
Parece texto, pero no se puede editar como texto; solo se edita como bloques con cuadros de diálogo de propiedades
Por supuesto, el lenguaje de scripting de RPG Maker no tenía cosas elegantes como una pila. Si necesitabas una subrutina reutilizable, tenías que asignar variables globales secretas para los parámetros, y no había reentrancia
Viéndolo en retrospectiva, creo que, con suficiente terquedad, se podrían haber implementado tanto registros como una pila de ejecución dentro de RPG Maker 2000
Al principio parece fácil. Puedes crear “registros” falsos como la zero page del 6502, y también una pila usando acceso indirecto a variables (https://rpgmaker.net/tutorials/523/)
El problema es que RM2K tiene concurrencia en forma de scripts de “parallel process”. Si los procesos paralelos usan estas abstracciones, distintos “hilos” se pisan el estado unos a otros
Por lo tanto, necesitas varias zero pages y pilas por cada “núcleo virtual”, y tienes que asignar/vincular/planificar núcleos virtuales para cada script paralelo. Es decir, de algún modo cada script debe tener un puntero de pila que solo él conozca
Para que sea estable incluso con condiciones de carrera, normalmente necesitarías algo como un mutex
Conociendo la obsesión de los desarrolladores de juegos en RPG Maker, sospecho que alguien habrá encontrado una forma de engañar a alguna función del runtime para que se comporte como un mutex, pero sinceramente me da miedo saber qué hicieron en realidad
Recuerdo haber descargado de rpgmaker.net un juego con un custom battle system implementado. Era una implementación que reemplazaba por completo el sistema de combate integrado usando técnicas como las que describiste
Cuando lo abrí en el editor para ver cómo funcionaba, quedé totalmente abrumado. Había cientos de “variables”, y si no recuerdo mal solo permitían i64, además de cientos de “interruptores”. Los interruptores eran booleanos
En ese momento no tenía ningún concepto de pila, heap ni llamadas a funciones
No puedo ni imaginar la cantidad de energía que habrá requerido construirlo y mantenerlo/depurarlo
Si no recuerdo mal, cuando escribía programas BASIC en la ZX81, lo hacía de una forma cercana a “sin pila”
1 GOTO 3010 LET C = A + B20 RETURN30 LET A = 140 LET B = 250 GOSUB 1060 LET A = C70 LET B = 380 GOSUB 1090 PRINT CRUN6En cierto modo, estaba haciendo yo mismo lo que el compilador hace en el artículo. Los números de línea eran direcciones de memoria, y las variables ocultas no estaban ocultas para mí. Porque yo era el compilador
Lo único que hacía el intérprete era guardar la dirección de retorno de
GOSUBEso sí, el código podría estar sintácticamente mal, o mi memoria podría estar distorsionada. Cuarenta años es mucho tiempo, pero la idea general es correcta
Además, el procesador Z80 dentro de la máquina sí tenía funciones de gestión de pila. El intérprete BASIC era realmente simple, pero tenía excusa: solo había 1 KB de RAM y 8 KB de ROM para el SO, el intérprete y todo lo demás
GOSUBguarda el número de línea u otra referencia queRETURNconsultará, y si anidas llamadas aGOSUB, tiene que recordar varios puntos de retorno, así que necesita algún tipo de pilaSolo que algunos BASIC, en lugar de una pila de propósito general, tenían un arreglo fijo de punteros de retorno y un índice de posición actual, de modo que, por ejemplo, la profundidad de llamada quedaba fija en 7. Para el programador, se comportaba como una pila de llamadas
Por supuesto, no era una pila “de verdad” con variables locales/parámetros, como uno esperaría al oír la palabra pila
En el entorno básico de BBC BASIC se podía hacer una demo divertida que mostraba qué pasaba durante llamadas anidadas, incluida la recursión. Si ubicabas la pila en la parte superior de la memoria de pantalla y evitabas dibujar ahí, podías ver cómo la pila crecía a medida que avanzaba el trabajo
Como la resolución de pantalla era baja, los 2 bytes de una dirección de retorno se veían como 8 píxeles gruesos en los modos de pantalla 1 o 5. En el modo 2 eran 4, pero con colores parpadeantes y quedaba peor; en los modos 0, 3, 4 y 6 eran 16, pero verlos a nivel de bits era más difícil de distinguir que una repetición de 8 colores
Antes de que existiera un heap ampliable arbitrariamente, los programadores aplicaban al menos algo de criterio de ingeniería
Porque tenían que considerar la distribución probabilística de las entradas y dimensionar adecuadamente todo el almacenamiento intermedio
Así nacieron las secciones de “BUGS AND LIMITATIONS”
Por eso todo se asigna estáticamente en tiempo de compilación, y tienes que saber cuánta memoria van a consumir las entradas
Pero conocer el límite superior del consumo de memoria también solía ser algo normal para los programadores de aplicaciones. Porque nunca quieres quedarte sin memoria
Hoy en día parece que simplemente dejamos el uso de memoria en modo YOLO
Por ejemplo, fue una gran mejora frente a restricciones como que la longitud máxima de los comandos de sed fuera finita y corta
Llevo tanto tiempo haciendo programación funcional que, sinceramente, me cuesta imaginar cómo escribir código sin recursión
Técnicamente sé cómo convertir algoritmos recursivos en algoritmos iterativos, y también lo he hecho en entornos con muchas restricciones de recursos, pero no me gusta
Por lo general, la versión recursiva es más elegante y, en el 99% de los casos, me parece suficientemente rápida. Si el compilador soporta recursión de cola, se acerca al 100%, aunque en la mayoría de los trabajos más interesantes de todos modos hay que mantener la pila manualmente
A veces hago ese tipo de cosas a propósito para aprender cómo se hacía antes de que yo naciera. De vez en cuando trasteo con juegos de Commodore 64, y eso me hace sentir con fuerza el lujo que es estar acostumbrado hoy a hardware rápido, barato y fácil de usar
Para hacer recursión en esas máquinas antiguas había que construir uno mismo el mecanismo de pila, y aun así seguían quedando problemas por resolver porque, básicamente, no había otra forma disponible más que el almacenamiento global
Viví esa época, pero no se la recomendaría a nadie
En la función
@letde Enhanced GNU Awk, para los bloques@letfuera de una función, por ejemplo dentro de bloquesBEGINoEND, hice que el compilador asignara variables globales secretasEstas variables se reutilizan entre bloques tanto como sea posible
$ ./gawk --dump-variables 'BEGIN { @let (a, b, c = 1) { } }'$ cat awkvars.out$let0001: untyped variable$let0002: untyped variable$let0003: 1ARGC: 1ARGIND: 0ARGV: array, 1 elementsBINMODE: 0[ .. snip many ]https://www.kylheku.com/cgit/egawk/about/
pingfunciona, ninc -z 104.37.63.7 443Actualización: parece que la infraestructura de seguridad está rota. No sé ni qué es eso y tampoco uso Twitter. Al revisar el AS, es Google Fiber
Y preferiría que no me doxearan