Conclusión
Sí, se necesitan pilas en la programación embebida. Es una buena idea surgida de una larga experiencia.
Breve historia
Soy lo suficientemente mayor como para haber trabajado en ordenadores que no tenían soporte de pila de hardware. (Siempre puedes fabricar tus propias pilas en software, obviamente, si tienes las instrucciones necesarias para algún tipo de referencias indirectas a la memoria, de todos modos). Así que quizás tenga algunas cosas que decir sobre el tema.
Le site Familia de procesadores de la serie HP 21xx es un buen ejemplo. En su día esta familia de procesadores se utilizaba habitualmente en configuraciones de doble procesador en los sistemas escolares como sistema de tiempo compartido para uso administrativo y/o estudiantil. La última edición de software que utilizó la familia de procesadores HP 21xx fue la Sistema de tiempo compartido HP 2000F . (Cambiaron a la HP 21MX pasando de la "F" a la "G").
El conjunto de instrucciones no soportaba las pilas para hacer una llamada a una subrutina. En su lugar, se pinchaba la primera posición de memoria con la dirección de retorno y se iniciaba la ejecución de la rutina en la siguiente dirección. Al final de la subrutina, habría una instrucción de salto indirecto que haría referencia a esa primera posición de memoria y saltaría de nuevo al llamador, justo después de la llamada, de esa manera.
Aquí hay algo de código real del sistema operativo BASIC (nótese el uso de MAYÚSCULAS - las minúsculas no siempre estaban disponibles):
STACH NOP
STB LTEMP+8 SAVE B-REG.
AND B177 CLEAR TOP BITS.
LDB STABF GET BUFFER POINTER.
CLE,ERB
SEZ,RSS
ALF,SLA,ALF CHARACTER ON LEFT.
IOR 1,I CHARACTER ON RIGHT.
STA 1,I
ISZ STABF BUMP POINTER.
LDA LTEMP+8 RESTORE OLD B-REG.INTO A.
JMP STACH,I
La última línea es la instrucción de retorno de la subrutina. Observe que siempre se utiliza un NOP como primera instrucción de una subrutina. Esto se debe a que siempre es "volado" y reemplazado por la dirección de retorno del llamador cuando la subrutina es llamada.
La instrucción RSS es un modificador que "invierte el sentido de salto" de lo que modifica. En este caso, una instrucción "Saltar la siguiente instrucción si Z=0" (SEZ.) Así que RSS invierte ese sentido y lo cambia a una instrucción "Saltar la siguiente instrucción si Z!=0".
Aquí hay un ejemplo cercano que en realidad tiene que jugar con la dirección de retorno porque quiere que una rutina diferente regrese directamente al llamador de esta rutina:
STAPR NOP
LDA STAPR SAVE RETURN ADDRESS.
STA T35SP
LDA T35B2 COMPUTE # OF CHARS.
CMA,INA
ADA STABF
LDB T35B2 RESET BUFFER
STB STABF POINTER.
LDB T35B1
JMP T35SP+1 OUTPUT.
Observe que la última instrucción salta a T35SP+1. Eso es una instrucción después de T35SP. (T35SP es otra función.) Observe también que esta rutina tuvo que copiar primero su propia dirección de retorno y meterla en la primera dirección de esta rutina a la que al final estarán saltando. Esto permite que la rutina a la que saltan (que utiliza una instrucción JMP,I para regresar) vuelva a su llamador, directamente. No hay pila, así que esto es más o menos como se hacía entonces.
(El CMA,INA es lo mismo que NEG A. Sólo significa complementar A y luego incrementar A. Podrías hacer una, la otra, o ambas en una sola instrucción).
Tenga en cuenta también que el ensamblador NO soportaba las etiquetas locales. Todas las etiquetas eran completamente globales. Esto significa que los programadores de la HP 21xx tenían que inventar continuamente nuevos nombres, ligeramente modificados, para evitar conflictos. ¡Y nada de minúsculas!
Sí. Lo tienes. Los tiempos eran difíciles entonces.
Por supuesto, todo el código del intérprete BASIC que proporciona capacidades de tiempo compartido para 32 usuarios simultáneos y así como todas las funciones trascendentales habituales, matrices, las operaciones matriciales habituales que incluyen la inversión de la matriz y los operadores determinantes de la matriz cabrían completamente dentro de 6k-palabras (16 bits). Todo el asunto.
Ahora pregúntese, "¿Qué podría hacer con sólo 12k bytes de espacio de código para trabajar?"
Siguiendo adelante,
Un problema inmediatamente obvio con este arreglo es que no puedes llamar a ninguna otra rutina que pueda necesitar llamar a la rutina en la que estás actualmente porque, si eso ocurriera, perderías la dirección de retorno del llamador original. ¡Y ciertamente no puedes llamarte a ti mismo recursivamente!
Así que si eso es un requisito, tienes que escribir un montón de código en la rutina para copiar la dirección a algún "pila de software" que tú creas. Y tienes que hacerlo en todos los lugares en los que pueda ser un problema.
Puede llegar a ser un dolor serio. He pasado por eso, lo he hecho.
Durante los años 60, hubo un proceso de aprendizaje sobre los mecanismos útiles.
Hay un proceso de aprendizaje por etapas que a veces se denomina síndrome del segundo sistema. O, al menos, entre mis compañeros de entonces. En este caso, el primer sistema no cumple con los objetivos necesarios en una variedad de formas y hay un fuerte impulso para utilizar lo que se ha aprendido con el fin de crear un segundo sistema más brillante. Pero el 2º sistema se excede con las características y, como resultado, los consumidores se quejan de que no pueden encontrar las cosas principales que realmente necesitan ya que están enterradas entre tantas opciones diferentes. Así que, después de recortar algunas características, justo en el tercer intento del sistema, el diseño es "más o menos". (Supongo que esto también podría ser la historia de Ricitos de Oro y los tres osos, donde la última cama que se intentó fue "justo a tiempo". )
Cuando se diseñó el PDP-11, era un "2º sistema" enfoque. Han proporcionado todas las formas posibles de tener pilas de soporte de hardware. No hay nada parecido, hoy en día. Nada tan bueno. Deberías tener la oportunidad de ver el conjunto de instrucciones. Es una maravilla en formas de llamar a otras rutinas. Puedes llamar a un código y tener su dirección de retorno colocada en un registro. Esto es genial para las co-rutinas rápidas. Puedes llamar a código y usar casi cualquier otro registro como puntero de pila para la dirección de retorno. No está atascado con sólo uno. ¿Quieres dos pilas de hardware? No hay problema. ¿Tres? También está bien. Cada registro se puede utilizar para: Registro , Registro Indirecto , Puntero de autoincremento , Puntero de disminución automática , Puntero de autoincremento indirecto , Autodecremento Puntero Indirecto , Puntero indexado y Puntero indexado Indirecto .
El PDP-11 era el paraíso de las pilas. La vida parecía buena.
Pero entonces... llegó el síndrome del tercer sistema. Ahora, estamos atascados con un "equilibrado" vista de los conjuntos de instrucciones.
Oh, bueno.
El marco de pila, también conocido como marco de activación
Una pila no es sólo una pila para contener direcciones de retorno. También es una herramienta importante para la compilación de código a partir de lenguajes fuente y para los programadores de ensamblaje que quieren una vida más fácil que tener que llevar la cuenta de cosas esparcidas por la habitación, en lugares aleatorios.
Esto lo elaboré hace mucho tiempo:
Lo anterior, mostrado en la región más blanca, es un típico marco de activación .
El diagrama anterior hace no definir todas las variaciones posibles. Sólo una de las más sencillas. Para ejemplo, Pascal requiere soporte para funciones anidadas y estas rutinas anidadas deben tener acceso a variables locales en sus cuerpos de código de código, por lo que los marcos de activación para el código Pascal compilado pueden ser un poco más complicado que lo anterior.
La persona que llama introduce los parámetros en "a [hilo] pila " y luego hace una llamada, que automáticamente empuja la dirección de retorno. Observe que en el caso anterior hay un parámetro de valor de retorno "opcional". Esto se debe a que a veces el valor de retorno puede ser un vector en lugar de algo que pueda caber fácilmente en un registro. (Los registros se utilizan a menudo como valores de retorno, cuando los datos resultantes caben fácilmente -- como los enteros). Así que la rutina llamada puede utilizar esta ranura para rellenar el valor de retorno del vector, si es apropiado. Pero es opcional en el sentido de que cada circunstancia determina si se necesita o no.
Una vez que la rutina llamada se inicia, lo primero que hace (usando prólogo ) es guardar registros especiales que deben ser preservados a través de las llamadas. Estos registros son los que la rutina que llama asume correctamente que no serán destruidos cuando llame a la subrutina. A veces se llaman "preservar los registros," pero no tengo ni idea de cómo los llaman los informáticos de hoy en día. El caso es que si la subrutina llamada necesita jugar con esos registros, entonces debe guardarlos para poder devolverlos, sin ser molestados, a la persona que llama.
Por separado, un registro diferente llamado "puntero del marco" o "puntero del marco de activación" también debe conservarse. El puntero del marco de activación siempre apunta al "marco actual" en la pila y proporciona a la subrutina en ejecución acceso a todo lo que necesita para realizar su trabajo.
(El orden en el que se produce la preservación anterior no es terriblemente importante. Hay ventajas y desventajas, independientemente de la elección).
Una vez terminado el prólogo necesario, la subrutina se pone a trabajar. Una vez terminado, la subrutina tiene que restaurar los registros conservados, restaurar el puntero de marco de activación y luego volver a su llamador. (El epílogo código).
Esto permite que una subrutina tenga sus propias variables locales que son locales a ella, incluso frente a la recursividad (llamándose a sí misma.)
Tomemos dos rutinas C no optimizadas y el antiguo conjunto de instrucciones x86 de 16 bits para hacer algunos ejemplos para mayor claridad.
Supongamos:
int f1( int a ) {
if ( a > 2 ) return a * f1( a-1 );
return a;
}
int f2( int a ) {
int r= a;
if ( a > 2 ) r= a * f2( a-1 );
return r;
}
He utilizado formas recursivas de calcular un factorial a partir de un valor suministrado. No quiero centrarme en la calidad o claridad del código anterior. Eso no es relevante. Sólo quiero centrarme en cómo se puede compilar esto, y por qué. Además, como hace tanto tiempo que no codifico rutinas x86 con regularidad, disculpen cualquier "palidez" de mi codificación. Es posible que haya olvidado algún detalle de sintaxis.
; Caller expects the function result in AX.
f1: push si ; save SI because caller assumes it is preserved.
push bp ; save caller's activation frame pointer.
mov bp, sp ; set up our own activation frame pointer.
mov ax, [bp+6] ; fetch parameter value into AX.
; the +6 part is there to skip the saved BP, SI,
; and the caller's return address.
cmp ax, #2 ; compare it against 2.
ble t1 ; exit the routine if <= 2, AX is already set.
mov si, ax ; save parameter value in SI where it is safe.
dec ax ; subtract 1 from the given parameter value.
push ax ; push this value as a parameter to a call.
call f1 ; now call ourselves to compute its factorial.
imul si ; multiply this result (in AX) by the saved parameter.
; the result will be in DX:AX, but we ignore DX.
t1: pop bp ; restore caller's activation frame pointer.
pop si ; restore SI.
ret ; result is already in AX so just return.
; Caller expects the function result in AX.
f2: push bp ; save caller's activation frame pointer.
mov bp, sp ; set up our own activation frame pointer.
sub sp, #2 ; reserve 2 bytes for local variable 'r'.
mov ax, [bp+4] ; fetch parameter value into AX.
; the +4 part is there to skip the saved BP
; and the caller's return address.
mov [bp-2], ax ; set local variable 'r' to parameter 'a'.
cmp ax, #2 ; compare 'a' against 2.
ble t2 ; exit the routine if <= 2, result 'r' is already set.
dec ax ; subtract 1 from the given parameter value.
push ax ; push this value as a parameter to a call.
call f2 ; now call ourselves to compute its factorial.
imul [bp-2] ; multiply this result (in AX) by 'r'.
mov [bp-2], ax ; save result in 'r' (ignore DX.)
t2: mov ax, [bp-2] ; put 'r' into the function result register.
pop bp ; restore caller's activation frame pointer.
ret ; just return.
Estos se dejan intencionadamente sin optimizar para mantener la coherencia con el código fuente. Un buen optimizador tendría un día de campo con el código anterior.
Quería proporcionar dos casos diferentes: el primero destaca la preservación de un registro que un llamador espera que no se cambie a través de la llamada y el segundo destaca cómo se asignan las variables locales utilizando la pila. En el segundo caso, observe lo fácil que es asignar - simplemente reste un número del puntero de la pila. Puedes restar un número mucho mayor, aún con una sola instrucción, para asignar grandes cantidades de almacenamiento de variables locales. Es realmente 'así de fácil' (¡Nótese también que estas variables locales no se inicializan!)
Si se trabaja con algunos ejemplos como éste, se verán muchos beneficios del concepto de pila.
No he descrito algo en la imagen. Hay un puntero opcional de manejo de excepciones. Esto es bastante útil cuando se manejan excepciones. Normalmente, esto puede ser NULL cuando esta rutina no incluye un manejador de bloque Try. Pero si lo hace, entonces este valor se establece en el código que manejará el error. Hay un proceso llamado "stack unwind" (en mis tiempos) donde una excepción que ocurre en una rutina que hace no tienen un manejador, desenrollará lentamente la pila hacia atrás a los llamadores anteriores hasta que encuentre uno que tenga un manejador instalado. Esta acción "vuela" el contexto de todas las subrutinas llamadas por debajo de la rutina que lleva un manejador de errores. Es una conveniente dispositivo . Pero más allá del contexto quería añadir aquí.
Una nota final. El orden y la forma en que los registros conservados y el puntero del marco de activación se introducen en la pila y se restauran desde ella no es una cuestión vital. Diferentes personas elegirán diferentes enfoques aquí. Sólo es importante saber qué elección se hace realmente, porque afecta a los desplazamientos utilizados en relación con el puntero del marco de activación cuando se accede a los valores de los parámetros o a las variables locales. El puntero del marco de activación se utiliza, sin embargo, para todos esos accesos ya que los parámetros y las variables locales son todos en relación con el puntero del marco de activación. Las posiciones exactas respecto a él no son importantes. Pero sí es importante que sus posiciones relativas sean fijo y que el compilador o el codificador de ensamblaje conozca los desplazamientos a utilizar y no tenga que realizar algunos cálculos en tiempo de ejecución para calcularlos.
¿Conclusión?
Sí, quieres pilas en la programación embebida. Es una buena idea surgida de una larga experiencia.
(Y ciertamente no querrá el conjunto de instrucciones HP 21xx implementado en su MCU si no implementa FeRAM o memoria de núcleo magnético -- ¡ya que requiere espacio de código escribible!)