33 votos

¿Qué hace que el concepto de pila sea útil en la programación embebida?

A continuación se muestra una ilustración del concepto de pila:

Enter image description here

He leído muchas veces sobre el puntero de pila y cómo algunas variables se almacenan en SRAM en una pila.

Muchos tutoriales explican cómo funciona, pero no por qué existe este concepto.

Imagina que soy el programa y quiero guardar mi taza, mi reloj y mi sombrero en mi habitación. ¿Por qué voy a guardarlos uno encima del otro en lugar de ponerlos en lugares aleatorios de mi habitación?

El programa puede llegar a los datos siempre que tenga su dirección. Pero si el puntero de la pila pone x en la parte superior y. ¿Cómo va a llegar a y sin eliminar x entonces? Así que para mí una pila debe hacer las cosas (el acceso a los datos) aún más difícil.

¿Puede dar un ejemplo muy sencillo con registros o código ensamblador o código C para ilustrar el beneficio de este concepto de apilamiento, a saber, "Last in, first out" (LIFO)?

110voto

AitorTheRed Puntos 241

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:

enter image description here

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!)

15voto

Justme Puntos 201

El lugar actual de ejecución se almacena en la pila cuando se inicia una subrutina, de modo que cuando ésta termina, puede salirse de la pila.

Esto permite una forma de almacenar el contexto de lo que el programa estaba haciendo antes de cambiar el contexto a otra subrutina, e incluso hace posible que la subrutina se llame a sí misma recursivamente, por lo que sólo sube un nivel al regresar. Eso es útil si una subrutina es llamada varias veces, no hace que se olvide de dónde se supone que debe regresar. Ese es el problema de sólo almacenar la dirección de retorno en algún lugar si no es una pila.

Así, durante la llamada a la subrutina, no es necesario acceder a ningún contexto de subrutinas que se estuvieran ejecutando antes de la subrutina que se llama. Este contexto incluye tanto las variables locales de una subrutina, como la dirección de retorno para volver a la subrutina anterior.

14voto

mkeith Puntos 2726

Usted nos dio una analogía, y ahora yo le daré otra.

¿Qué pasaría si recibiera a la gente en la puerta de un establecimiento y recogiera el abrigo y el sombrero de cada persona que entrara a visitarlo? Si supieras con certeza que la última persona en visitarte sería la primera en salir, podrías almacenar los sombreros y abrigos en una estructura similar a una pila. Entonces, a medida que cada persona se marchara, le entregarías su abrigo y su sombrero y pasarías al siguiente sombrero y abrigo de la pila con un mínimo de molestias y complicaciones. Así es como funcionan (hasta cierto punto) las llamadas a funciones en un programa informático.

Las funciones pueden llamar a otras funciones en cualquier orden, pero cada vez que se produce una llamada a una función, el apilamiento aumenta un nivel. Cada vez que una llamada a una función regresa, el apilamiento se reduce en un nivel.

Dado que las funciones utilizan memoria temporal y que el orden exacto en el que serán llamadas no está necesariamente predeterminado, la pila es una gran manera de manejar esto. La idea de la dirección de memoria permanente funcionaría bien para los programas sin mecanismo de llamada a funciones, pero haría mucho más difíciles las llamadas a funciones recursivas.

El concepto de pila se da con suficiente frecuencia en el procesamiento de datos que incluso los programas de alto nivel a veces utilizan estructuras de datos de pila extravagantes (estructuras de datos de última entrada, primera salida).

9voto

JJdaCool Puntos 11

Imagina que soy el programa y quiero guardar mi taza, mi reloj y mi sombrero en mi habitación. ¿Por qué voy a guardarlos uno encima del otro en lugar de ponerlos en lugares aleatorios de mi habitación?

Estás viendo esto de manera equivocada: imagínate no como el conjunto programa, sino como una subrutina que puede ser puesta en cualquiera de una variedad de programas.

Los programas de cualquier tamaño significativo se construyen generalmente de forma modular a partir de pequeños "componentes" o rutinas de software. Las rutinas pequeñas son más más fáciles de probar y ofrecen más flexibilidad a la hora de combinarlas para para hacer un programa más grande y reutilizarlo entre programas. Sin embargo, para que esto funcione es vital que estas rutinas no interfieran entre sí, incluso cuando una llama a otra.

Una de las formas en que pueden interferir es en el uso de las ubicaciones de memoria. Si tienes dos rutinas diferentes que estás combinando para usar dentro de un programa la rutina A utiliza la posición de memoria 6 para almacenar algunos de sus datos, y llama a la rutina B, que también utiliza la posición 6 (aunque sólo sea de forma temporal hasta que B se complete), la rutina B sobrescribirá los datos de la rutina A y rutina A se romperá.

Esto puede evitarse hasta cierto punto llevando un control cuidadoso de quién utiliza qué lugares y quién llama a quién, pero a medida que aumenta el número de rutinas las posibles interacciones entre ellas aumentan exponencialmente. Por lo tanto, es útil tener una forma de minimizar estas interacciones y una pila permite que al hacer reentrada más fácil. Cuando una rutina necesita guardar algo temporalmente, siempre puede empujar a la pila y más tarde sacar ese valor de la pila, sin que le afecte en absoluto lo que se haya colocado en la pila por cualquiera de los códigos que conducen a la rutina que se llama y también no se ve afectada por el uso de la pila en cualquier código que llame. (Por supuesto, todas las rutinas deben acordar que siempre, antes de salir, sacarán de la pila cualquier cosa que hayan introducido antes en la pila).

El ejemplo más sencillo es imaginar que eres una rutina que necesita utilizar el registro A, pero te llaman con un valor (para ti) aleatorio en el registro A que debes preservar. (Por ejemplo, si te llaman a través de un interrupción, el registro A contendrá cualquier valor que otra rutina haya en medio del proceso y que no debe ser cambiado cuando la otra rutina rutina se reanude o se romperá). En este caso puedes empujar el registro A en la pila, pasar a utilizarlo para sus propios fines, y luego sacar el registro A de la pila cuando haya terminado, restaurando su valor original y restaurando su valor original y dejando la pila exactamente como la encontraste.

Si desea un ejemplo un poco más detallado de las pilas que resuelven el problema de reentrada, también puedes echar un vistazo a esta respuesta de la bolsa de trabajo de Retrocomputing.

El programa puede llegar a los datos siempre que tenga su dirección. Pero si la pila pone x en la parte superior y. ¿Cómo va a llegar a y sin quitar x entonces? Asi que para mi la pila debe hacer las cosas (el acceso a los datos) aun mas dificil.

Bueno, hace que los accesos "hacia atrás en la pila" sean más difíciles; generalmente cuando uno necesita hacer esto habrá una secuencia de punteros en la pila que le permiten retroceder, rutina por rutina, encontrando el "marco de pila" de cada una para poder acceder a sus datos. (Así, podrías buscar el sombrero que almacenado, el sombrero almacenado por la rutina que lo llamó, un sombrero diferente almacenado por la rutina que lo llamó, y así sucesivamente). Esta no es una técnica inusual técnica, pero explicarla en detalle probablemente esté fuera del alcance de la respuesta que estás buscando.

7voto

Marko Buršič Puntos 1524

Pero si el puntero de la pila pone x en la parte superior y. ¿Cómo va a llegar a y sin sin remover x entonces? Así que para mí la pila debe hacer las cosas (acceso a los datos) aún más difíciles.

No es necesario. Estas variables se almacenan temporalmente en la pila en el orden exacto (push), luego se recuperan en el orden inverso.

En cada llamada de función, interrupción,... el procesador empuja los registros en la pila, el más importante de ellos es el PC - contador de programa, es la última posición en el programa antes de que salte a otra posición en la memoria del programa. Cuando se llama a la función, entonces salta a otra posición, procesa la función y luego en el orden inverso hace saltar los registros, por último el PC y comienza a ejecutarse donde se interrumpió.

i-Ciencias.com

I-Ciencias es una comunidad de estudiantes y amantes de la ciencia en la que puedes resolver tus problemas y dudas.
Puedes consultar las preguntas de otros usuarios, hacer tus propias preguntas o resolver las de los demás.

Powered by:

X