Tiempo Real para Sistemas Mecatrónicos - IvanCS-Chenfu/Master GitHub Wiki
-
- 2.1. Introducción
- 2.2. Tipos de Datos Simples
- 2.3. Prefijos para los Tipos de Datos Simples
- 2.4. Variables y Constantes
- 2.5. Tipos de Datos Compuestos
- 2.6. Renombrado de Tipos
- 2.7. Expresiones
- 2.8. Control de Flujo
- 2.9. Rutinas
- 2.10. Modularidad/Organización del Programa
- 2.11. Punteros
- 2.12. Registros Datasheet ATmega328P
Distintas Definiciones de Tiempo Real:
- Es un sitema de tiempo real si y solo sí responde a eventos externos antes de tiempos finitos conocidos.
- Es un sistema de tiempo real si y solo si su comportamiento produce resultados correctos y a tiempo.
- Es un sistema de tiempo real si y solo si produce una respuesta correcta ante un evento. La respuesta la producirá con una probabilidad dentro de una región de tiempo finita (la probabilidad y la región deben de ser conocidas de antemano). La región no tiene por que ser en tiempos rápidos.
Como se puede observar, parte de la campana de gauss está fuera de la región verde. Eso es normal (hay probabilidad de q no se cumpla el rango de tiempo, sin embargo, sigue siendo de tiempo real). Si la camapana se encuentra totalmente dentro de la región verde, es un proceso "Determinista".
Definiciones:
- Deadline: El mayor tiempo permitido para responder a los eventos (extremo final de la zona verde).
- WCET (Worst Case Execution Time): El peor tiempo que le lleva a un sistema procesar un evento. Si queremos un sistema determinista, tendrá que ser menor que el "Deadline".
- ACET (Average Case Execution Time): El tiempo medio que le lleva a un sistema procesar un evento.
- JITTER: Es la varianza de lo que tarda un sistema en responder.
- LATENCIA: Tiempo desde que el evento aparece hasta que el sistema esté en el momento de procesarlo.
Según Determinismo:
- Deterministas/Duros/Críticos: Tienen una probabilidad de responder en su región verde de 1. Nunca violan un "Deadline" (esto en la práctica es imposible, pero haces que sea determinista en situaciones deseadas). Existen los normales (tiempos de "Deadline" mayores a los milisegundos) y los reales (tiempos menores a los milisegundos).
- Suaves/No Críticos: Tienen una probabilidad menor a 1 de responder en la región verde. Pueden violar el "Deadline". Sin embargo, hacen que ek ACET esté dentro de la región verde. Existen los normales (aunque se pasen el "Deadline", nunca fallan) y los firmes (al pasar el "Deadline", pueden fallar).
Según la Escala:
- Pequeña Escala: Tienen pocos recursos. Por ejemplo (micro controladores, PLCs...): una sola CPU (1 core), no hay S.O. y no hay red. Casi todos los sistemas deterministas son de pequeña escala.
- Media y Larga Escala: Con sistemas operativos de cierta complejidad (multiprocesadores, ordenadores, redes de ordenadores...)
Generales:
- El determinismo depende de todos y cada uno de los componentes del sistema (Hardware y Software). Para que un sistema sea determinista, todos sus sitemas tienen q ser deterministas.
- Todos los sitemas son heterogéneos, es decir, todos los componentes que lo componen son de una disciplina diferente (teleco, software, electrónica...).
- Tienen que ser eficientes.
- Tiene que ser tolerante a fallos (situaciones imprevistas). Por ejemplo, un avión tiene 4 ordenadores haciendo lo mismo por si uno se rompe.
Sistemas de Cómputo:
- Necesidad de temporización: Medir el tiempo y ejecutar el código en ciertos momentos.
- Casi siempre vamos a necesitar multitarea ya que necesitaremos atender a varios eventos.
- Se necesitan interfaces hardware con el mundo físico.
- Se necesitan interrupciones hardware para responder a los eventos a tiempo.
- Necesita tratar números reales y no enteros (en el ordenador tratas con numeros enteros pero, puedes trabajar con voltajes con decimales).
- El bit más significativo (MSB) es el de la izquierda y el menos (LSB) el de la derecha. Los números negativos están en complemento a 2 (
$2^n + NumeroNegativo$ ). Los números reales pueden estar en coma fija (primeros 4 bits para entero y ultimos para decimales) o en coma flotante.
- C es muy utilizado para Sistemas Embebidos, Sistemas Operativos y para cuestiones muy cercanas a la máquina. Es el programa que incluye mas opciones para programar (android, windows, linux...).
- C es un lenguaje de alto-medio nivel de abstracción (tratas con memoria finita, variables finitas...).
- Hay varios estándares: c89, c90, c99 (pocos compiladores lo implementan completamente). Aquí vamos a utilizar c90 (que es igual a c89). La versión está metida en una variable llamada
__STDC_VERSION__.
-
char: Guarda un entero de un byte (ASCII) o tantos bytes como la máquina considere que ocupa un carácter (UTF-8). Normalmente en "c", el tamaño de un byte va a ser el número de bytes que conformen un dato de tipo ````char```. Este entero puede tener signo (pero no obligatoriamente, depende de la máquina). -
int: Entero de al menos dos bytes con signo. Mide los mismo bytes que los registros de la CPU (normalmente 2 bytes). -
float: Real de coma flotante con mínimo 6 decimales de precisión.
Con la función sizeof(<TIPO_DATO>) obtenemos el número de bytes de dicho tipo en memoria.
Prefijos del Tamaño:
-
short <TIPO_DATO>: El resultado tiene el mismo o menor tamaño en memoria. No existe ni elshort floatni elshort char(ya que el tamaño dechares el decidido por la máquina para ser un byte). -
long <TIPO_DATO>: El resultado tiene igual o más tamaño. No existe ellong chary ellong floatse debe escribir comodouble.
Prefijos de Signo:
-
unsigned <TIPO_DATO>: Se gana un bit (el bit del signo). No existe elunsigned float. -
signed <TIPO_DATO>: Fuerza a que haya signo. Loscharno se sabe muchas veces si tiene o no signo.
Otros Prefijos:
-
volatile <TIPO_DATO>: Significa que la variable puede ser modificada desde fuera del programa (como un temporizado hardware). -
enum <NOMBRE_NUEVO_DATO> {<NOMBRE_1>, <NOMBRE_2>, ...}: Son equivalentes a unint¿?¿?¿? -
enum <NOMBRE_NUEVO_DATO> {<NOMBRE_1> = <NÚMERO_1>, <NOMBRE_2> = <NÚMERO_2>, ...}: Son equivalentes a unint¿?¿?¿?
Conversiones de Tipos:
-
(float)8: Pasa8de tipointa tipofloat -
(enum <NOMBRE_NUEVO_DATO>)9.2: Pasa9.2de tipofloata tipoenum <NOMBRE_NUEVO_DATO>
Guardado en memoria:
- Automático: La memoria automática (pila) es variable. La CPU guarda las direcciones de retorno de las rutinas (funciones) en la memoria RAM en las direcciones altas de la memoria RAM (la pila). Las variables locales (se crean al entrar en la rutina y se eliminan al salir de ella) también se almacenan en la pila de la memoria RAM.
- Estática: La memoria estática es fija y se encuentra en las direcciones bajas de la memoria RAM. Aquí se almacenan las variables globales (están fueras de toda rutina y toda rutina puede ver).
- Dinámica: La memoria dinámica (heap) es variable, ya que es la resta entre la pila y la memoria estática. Se encuentra en las direcciones medias de la memoria RAM. Aquí se guardan las variables creadas dinámicamente durante la ejecución. No es útil para tiempo real duro debido a que acceder a ella utiliza instrucciones complejas (aumenta el jitter).
- Memoria de Programa: Las constantes no van a la RAM, sino que van a la memoria de programa (En muchos ordenadores la memoria de programa se encuentra en la memoria RAM, sin embargo, en microcontroladores no. En el "ATmega328P", la memoria del programa se encuentra en la memoria Flash).
Declaración de Variables: <TIPO_DATO> <NOMBRE_VARIABLE>;. Estas variables no tienen ningún valor definido, excepto algunas variables locales poniendo el prefijo static <TIPO_DATO> <NOMBRE_VARIABLE>; (se guardan en la memoria estática y no se eliminan al salir de la rutina) que se inicializan a 0. Todas las variables se han de declarar al principio de la rutina (lo obliga el estandar c90).
Declaración de Constantes: const <TIPO_DATO> <NOMBRE_CONSTANTE> = <VALOR_CONSTANTE>;. Estas constantes no se pueden usar siempre (por ejemplo, en el tamaño de un array). Para poder usar las constantes siempre, se pueden declarar como macros (#define <NOMBRE_CONSTANTE> <VALOR_CONSTANTE>). Las macros no se compilan sino que se preprocesan (sustituye <NOMBRE_CONSTANTE> por <VALOR_CONSTANTE> en el código y luego compila). Las macros también se pueden utilizar como funciones (#define prod(x,y) (x)*(y) --> prod(2,3) pasará a (2)*(3))
-
<TIPO_DATO> <NOMBRE_VARIABLE_ARRAY> [<TAMAÑO_1>][<TAMAÑO_2>]...;: Crea una tabla (matriz) de tamaño "$$TAMAÑO_1 \times TAMAÑO_2 \times ...$$ " en la que todos sus elementos son de tipo<TIPO_DATO>. En memoria se almacenan sin huecos, todos los elementos seguidos. Los arrays de tipocharmeten siempre al final un elemento con valor0. -
Los structs almacenan varios tipos de datos. En memoria se almacenan pero no de forma contigüa. Se almacenan en orden pero pueden existir saltos.
struct
{
<TIPO_DATO_1> <NOMBRE_DATO_1>;
<TIPO_DATO_2> <NOMBRE_DATO_2>;
\vdots
<TIPO_DATO_N> <NOMBRE_DATO_3>;
} <NOMBRE_VARIABLE_STRUCT>;
// En una rutina
<NOMBRE_VARIABLE_STRUCT>.<NOMBRE_DATO_1> = <VALOR QUERIDO>;struct <NOMBRE_TIPO_STRUCT>
{
<TIPO_DATO_1> <NOMBRE_DATO_1>;
<TIPO_DATO_2> <NOMBRE_DATO_2>;
\vdots
<TIPO_DATO_N> <NOMBRE_DATO_3>;
}
// En una rutina
struct <NOMBRE_TIPO_STRUCT> <NOMBRE_VARIABLE_STRUCT>;- Crea una unión. Una unión es un tipo igual al struct pero que todos los campos se almacenan en la misma dirección de memoria (solapados). Esto es útil si por ejemplo, dentro de un struct tenemos dos variables y cada cual se utiliza cuando la otra no (Si se utiliza uno se guarda esa información en memoria y la otra no. Si se utiliza el otro, alrevés).
-
typedef <NOMBRE_ANTIGUO> <NOMBRE_NUEVO>;: Se compila y cambia un texto por otro en todo el programa.
- Rutinas Aritméticas:
+,-,/*, y%. - Rutinas Lógicas:
!=,==,<,>,<=,>=,!,&&y||. En c,0es falso y cualquier otro número es verdadero (no existe variable booleana, son enteros). - Manipulación de Bits:
<<(mover bits a la izquierda),>>(mover bits a la derecha),^(xor),~(not),&(and) y|(or).
- La sentencia
if/elserealiza una acción si se cumple (o no) una condición.
if (<CONDICIÓN_LÓGICA_1>)
{
<CUERPO_1>
}
else if (<CONDICIÓN_LÓGICA_2>)
{
<CUERPO_3>
}
else
{
<CUERPO_3>
}- La sentencia
switchrealiza una acción por cada valor que tenga una variable.
switch (<VARIABLE>)
{
case <VALOR_1>:
{
<CUERPO_1>
break; /*Sale de Switch*/
}
case <VALOR_2>:
{
<CUERPO_2>
break;
}
case <VALOR_3>:
case <VALOR_4>:
case <VALOR_5>: /* Casos con el mismo cuerpo*/
{
<CUERPO_3_4_5>
break;
}
default: /*Si no se cumple ninguno de los casos anteriores*/
{
<CUERPO_DEFAULT>
break;
}
}- La sentencia
whileejecuta el cuerpo en bucle mientras se cumpla la condición.
while (<CONDICIÓN_LÓGICA>)
{
<CUERPO>
/*También se puede utilizar "break;" para salir del bucle*/
/*Si utilizamos "continue;" termina la iteración actual, pero no sale del bucle.*/
}- La sentencia
do whileejecuta el cuerpo en bucle hasta que se cumpla una condición. Este bucle siempre se ejecuta mínimo una vez.
do
{
<CUERPO>
}while (<CONDICIÓN_LÓGICA>);- La sentencia
forejecuta el cuerpo en bucle para contar. El bucle se ejecutará mientras se cumpla la <CONDICIÓN_LÓGICA>. Esta condición dependerá de una <VARIABLE_INICIAL> que irá modificando su valor en cada iteración (por ejemplo:i++).
for (<VARIABLE_INICIAL> ; <CONDICIÓN_LÓGICA> ; <MODIFICACIÓN>)
{
<CUERPO>
}La rutina es una función la cual al llamarse se le pasan parámetros de ciertos tipos (variables locales dentro de la rutina), trata dichos parámetros dentro del cuerpo y devuelve un valor cuyo tipo de dato sea el de la función. Si no quiero devolver ningun valor, el <TIPO_DATO> es igual a void. Si no quiero parámetros en mi función, entre los paréntesis pongo también void.
<TIPO_DATO> <NOMBRE_RUTINA>(<TIPO_DATO_1> <PARÁMETRO_1>, <TIPO_DATO_2> <PARÁMETRO_2>,...); /*Prototipo*/
<TIPO_DATO> <NOMBRE_RUTINA>(<TIPO_DATO_1> <PARÁMETRO_1>, <TIPO_DATO_2> <PARÁMETRO_2>,...) /*Rutina*/
{
<CUERPO>
return <VARIABLE_DE_TIPO_DATO>
}
<NOMBRE_RUTINA>(<TIPO_DATO_1> <VALOR_1>, <TIPO_DATO_2> <VALOR_2>,...); /*Llamar Rutina*/Para tiempo real duro no se deberían usar llamadas recursivas (no controlo el tiempo ni las variables creadas en la pila).
Los parámetros pasados a la rutina son nuevas variables locales que se crean en la pila con el valor dado. Si se modifica su valor dentro de la rutina, al ser una variable copiada, no se modifica el valor de la variable de fuera de la rutina que ha sido utilizada como parámetro. En c++, para modificar el valor de la variable fuera de la rutina se debe pasar la variable por referencia (se pasa la dirección en memoria de dicha variable), sin embargo, c no lo tiene. Por ello, por eficiencia, no se debería pasar tipos compuestos como parámetros ni que sean devueltos por la rutina.
En cada módulo (menos el que empieza la ejecución) debería haber 2 ficheros. El archivo header (.h) el cual muestra todas las definiciones públicas (variables, rutinas, etc. que puedan utilizar otros módulos) y el archivo código (.c) que mostrará las definiciones privadas (variables, rutinas, etc. que no quiero que los otros módulos vean) y realizará el código necesario con las variables del header.
El módulo que empieza la ejecución solo tendrá el archivo código, ya que este se encargará de llamar al resto de módulos. Este archivo será el único que tenga la rutina int main(void) (utilizado cuando el controlador que ejecuta el programa no tiene S.O.) o int main (int argc, char **argv) (Cuando hay S.O. se le pueden pasar parámetros al ejecutable). Con el fin de utilizar otros módulos utilizaremos #include "<NOMBRE_MÓDULO>.h" (tipicamente se utilizan "" para módulos propios y <> para módulos que vienen ya en el lenguaje.
Cada módulo da un archivo .obj (en windows) o .o (en linux) al compilarse, y todos juntos dan el archivo .exe (en windows) o sin extensión (en linux).
Las disposiciones en memorias de cada módulo no están claras hasta que se crea el ejecutable (se pone el código seguido, todas las variables juntas, etc...).
Es un tipo de dato simple que guarda una dirección de memoria + el tipo de dato apuntado.
Declaración: <TIPO_DATO>* <NOMBRE_PUNTERO>;
Dar valor:
-
int* <NOMBRE_PUNTERO> = (int*)0x<DIRECCIÓN_MEMORIA>;: Es un caster de un valorint(el valor de0x<DIRECCIÓN_MEMORIA>es uninthexadecimal) a un valorint*. -
<NOMBRE_PUNTERO> = &<NOMBRE_VARIABLE>;: El operador&devuelve la dirección en memoria de la variable<NOMBRE_VARIABLE>. -
<NOMBRE_PUNTERO> = NULL;: Necesaria la librería#include <stdlib.h> -
<NOMBRE_PUNTERO> = (int*)malloc(sizeof(int));: Necesaria la librería#include <stdlib.h>. Me busca un espacio en el heap de la memoria RAM en el que quepa unint. Para liberar ese espacio de memoria usaremosfree(<NOMBRE_PUNTERO>);. Esto es muy útil para devolver punteros de una rutina (el que llame a la rutina se tiene que encargar de liberarlo).
Uso del puntero:
-
<NOMBRE_VARIABLE> = *<NOMBRE_PUNTERO>;: El valor de<NOMBRE_VARIABLE>es el valor de lo apuntado por<NOMBRE_PUNTERO>. -
*<NOMBRE_PUNTERO> = <VALOR>;: Se le asigna un valor a la variable apuntada por el puntero. Esto es muy útil en los tiposstructya que sip = &<VARIABLE_STRUCT>;, podemos darle valor a las variables internas(*p).x = <VALOR>;. Esto último se puede traducir porp->x =<VALOR>;. - Puedo pasar un puntero como parámetro de una rutina pero si pongo el prefijo
constya que no puedo modificar el valor del contenido del puntero, solo puedo consultarlo para utilizarlo. (En realidad si no ponesconstpuedes modificarlo, no sé por que dice antes que no se puede pasar por referencia en c).
Álgebra de punteros:
-
<NOMBRE_PUNTERO>+=<VALOR>;: Aumenta la dirección<VALOR>veces el tamaño del tipo de dato del puntero. - Si creamos un array, el valor de
<NOMBRE_ARRAY>es un puntero que apunta al primer elemento del array. Esto quiere decir que<NOMBRE_ARRAY>[2]=1;es igual que*(<NOMBRE_ARRAY>+2)=1;(le asigno 1 a la dirección de memoria 2 veces superior al inicio del array).
-
TCCR0Aes igual aTCCR1Ay aTCCR2A.
- ```WGM01:WFM00```: Lo ponen en modo CTC (```010```) o en modo PWM (```001```).
- ```COM0A1:COM0A0```: Acciones al realizar cuando el contador llega a ```OCR0A```. Depende del modo (CTC o PWM).
- ```COM0B1:COM0B0```: Acciones al realizar cuando el contador llega a ```OCR0B```. Depende del modeo (CTC o PWM).
-
TCCR0Bes igual aTCCR2B(TCCR1ByTCCR1Cson diferentes pero las diferencias no se tocan).
- ```WGM02```: Es el bit que faltaba antes para poner el modo CTC o en PWM.
- ```FOC0A:FOC0B```: No se tocan
- ```CS2:CS0```: Cambian el preescalado.
-
Los valores del timer se ven en un byte llamado
TCNT0oTCNT2(TCNT1HyTCNT1Lpara el timer 1) y el contador se compara con los bytesOCR0AoOCR2A(OCR1AHyOCR1ALpara el timer 1) o los registrosB. -
TIMSK0es igual alTIMSK2que son diferentes aTIMSK1(tiene el bitICIE1que no sé lo que hace).
- ```OCIE0B```: Si está a ```1``` salta una interrupción cuando el timer llega a ```OCR0B```.
- ```OCIE0A```: Si está a ```1``` salta una interrupción cuando el timer llega a ```OCR0A```.
- ```TOIE0```: Si está a ```1``` salta una interrupción cuando el timer llega al final.
-
TIFR0es igual alTIFR2que son diferentes aTIFR1(tiene el bitICF1que no sé lo que hace).
- ```OCIF0B```: Se ponen a 1 cuando ocurre la interrupción de ```OCIE0B```. Se borra solo al terminar la interrupción o cuando el usuario le escribe un ```1```.
- ```OCIF0A```: Se ponen a 1 cuando ocurre la interrupción de ```OCIE0A```. Se borra solo al terminar la interrupción o cuando el usuario le escribe un ```1```.
- ```TOV0```: Se ponen a 1 cuando ocurre la interrupción de ```TOIE0```. Se borra solo al terminar la interrupción o cuando el usuario le escribe un ```1```.
-
DDRBes igual aDDRCy aDDRD.
- Valor ```1```: Puerto de salida.
- Valor ```0```: Puerto de entrada.
-
PORTBes igual aPORTCy aPORTD.
- Si es puerto de salida: escribir un ```0``` da 0V y escribir un ```1``` da 5V.
- Si es puerto de entrada: escribir un ```0``` desactiva el pull-up y escribir un ```1``` activa el pull-up.
-
PINBes igual aPINCy aPIND.
- Lee el valor del pin y devuelve un estado lógico (```0``` o ```1```).
ADMUX
- ```REFS1:REFS0```: Eligen el voltaje de referencia del ADC (desde 0V hasta Vref).
- ```ADLAR```: Elige si los 10 bits dados por el ADC se escriben a la izquierda o a la derecha de los 2 registros ```ADCL``` y ```ADCH```.
- ```MUX3:MUX0```: Eligen el puerto del cual se va a leer la señal analógica.
ADCSRA
- ```ADEN```: Habilita el ADC.
- ```ADSC```: Inicia una conversión.
- ```ADATE```: Si en el ```ADCSRB``` ponemos algún ```ADTS```, este bit se tendrá que pone a 1 para que la conversión se inicie cuando ocurra un el evento dado por ```ADTS```.
- ```ADIF```: Se pone a 1 cuando termina una conversión. Se limpia escribiendo un 1 o al entrar en la ISR del ADC.
- ```ADIE```: Activa las Interrupciones del ADC.
- ```ADPS2:ADPS0```: Elije el preescalado del ADC (frecuencia de muestreo).
ADCSRB
- ```ACME```: ni idea
- ```ADTS2:ADTS0```: Elige cuando quieres que se inicie una conversión del ADC.
- Crear un Proyecto:
file >> New Project... >> Microchip Embedded >> Application Project(s) >> Next.
Después de ello elegiremos el dispositivo ATmega328P de la familia 8-bit AVR MCUs (...). En cuanto al campo tool usaremos Simulator.
En cuanto al comilador elegiremos el XC8 (...). Y finalmente le daremos nombre al proyecto desmarcando la casilla Open MCC on Finish.
- Configuración del Proyecto:
En la pestaña de
Projectsde la izquierda le daremos clic derecho a nuestro proyecto y accederemos aProperties >> Conf: [default] >> Loadingy marcaremos la casilla deLoad symbols when programming or building for production.
Accederemos a Properties >> Conf: [default] >> XC8: Global Options >> XC8 Compiler y en el desplegable superior Option categories ponemos Optimizations. Finalmente ponemos 0 en Optimization level
-
Creación de la Hoja de Texto
.c: En la pestaña deProjectsde la izquierda le daremos clic derecho aSources Filesy pulsaremosNew >> avr-main,c... -
Compilar: Vamos a la pestaña superior
Debug >> Discrete Debugger Operation >> Build for Debugging Main Project. -
Run: Le damos al simolo de la imagen siguiente y pulsamos en
Debug Main Project.
Nuestro código será el siguiente:
unsigned int count; /*Variable Global*/
int main(void) {
unsigned char x = 0xFF; /*unsigned char = byte*/
count = 1000;
while (1)
{
x^=0xff;
++count;
}
}- Pulsando en los números a la derecha del código podemos hacer que cuando pulsemos "Run", el programa se pare antes de que ejecute dicha línea.
-
Podemos ver los datos guardados en la RAM del dispositivo pulsando en la pestaña superior
Window >> Target Memory Views >> SRAM Data MemorySi hemos parado el programa en la líneaunsigned char x = 0xFF;veremos al correr el programa como el valor de la dirección8F0tiene el valorFF. -
Podemos ver las acciones en ensamblador (como ver lo registros utilizados) si pulsamos en la pestaña superior
Window >> Debugging >> Disassembly. Para poder utilizarlo será necesario haber parado el programa pulsando los números a la derecha del código. Se abrirá una pestaña en la que veremos las acciones realizadas por cada comando después del comentario de cada comando. Podemos pasar de cada acción del ensamblador pulsando el siguiente símbolo.
Los comandos LDI asignan un valor a un registro. Los comandos STD asignan el valor del registro a una dirección de la pila de la RAM. Los comandos STS asignan el valor del registro a una dirección de la memoria estática de la RAM. Los comandos LDD asignan el valor de una dirección de la pila de la RAM a un registro. Los comandos LDS asignan el valor de una dirección de la memoria estática de la RAM a un registro
-
Los valores de cada registro se mostrarán en la pestaña superior
Window >> Target Memory Views >> I/O Data Memory -
Los tiempos en los que se ejecuta cada linea del código se observar en la pestaña superior
Window >> Debugging >> Stopwatch
Nuestro código será el siguiente:
unsigned int count; /*Variable Global*/
int addition (int a, int b)
{
return(a+b);
}
int main(void) {
unsigned char x = 0xFF; /*unsigned char = byte*/
count = 1000;
while (1)
{
x=(unsigned char)addition(x,count);
++count;
}
}-
Con el fin de ver la memoria de programa (prácticamente vemos lo mismo que cuando abrimos los comandos del ensamblador) pulsaremos en la pestaña superior
Window >> Target Memory Views >> Program Memory. -
Con el fin de ver como funciona la llamada a una rutina, veremos en el ensamblador que en el "main", la siguiente instrucción a la llamada es la
0x79. Esto se guardará en el momento de la llamada en la dirección de memoria de la pila de la RAM dicha por el registro con nombreSPcon el fin de volver a dicha instrucción cuando termine la rutina.
Se deberían borrar los datos de la pila al terminar la rutina pero no lo veo.
Nuestro código será el siguiente:
typedef struct{
int x;
char c[3];
float f;
} MyStructType;
char d[3];
MyStructType mst;
int main(void) {
d[0] = 1;
d[1] = 2;
d[2] = 3;
mst.x = 10;
mst.c[0] = 'a';
mst.f = 10.0;
return(0);
}- Se observa como, en este caso, todas las variables están ordenadas en la memoria estática.
Nuestro código será el siguiente:
int main(void) {
unsigned f;
unsigned char x = 0xff;
f = 1000;
if (f == 1000)
++x;
else
--x;
for (f = 0; f < 1000; ++f)
x^=0xff;
f = 0;
do
{
x^=0xff;
++f;
}while (f<1000);
return(0);
}- Sentencia
if: Carga el numero, lo compara y si no es igual salta a la instrucción0x55.
0x4C: LDD R24, Y+1
0x4D: LDD R25, Y+2
0x4E: CPI R24, 0xE8
0x4F: SBCI R25, 0x03
0x50: BRNE 0x55
- Sentencia
for: Carga el número, lo compara y si es igual salta a la instrucción0x5B
0x63: LDD R24, Y+1
0x64: LDD R25, Y+2
0x65: CPI R24, 0xE8
0x66: SBCI R25, 0x03
0x67: BRCS 0x5B
Realiza el cuerpo del bucle y suma 1 a f.
0x5B: LDD R24, Y+3
0x5C: COM R24
0x5D: STD Y+3, R24
0x5E: LDD R24, Y+1
0x5F: LDD R25, Y+2
0x60: ADIW R24, 0x01
0x61: STD Y+2, R25
0x62: STD Y+1, R24
-
Sentencia
do while: Realiza todo, compara y si es verdad vuelve a la instrucción0x6A. -
Para ver el valor de una variable en cada instante pulsa en la pestaña superior
Window >> Debugging >> Whatches, escribe el nombre de la variable que quieres ver y pulsa enter.
Hablaremos del uso de los puertos del ATmega328P utilizando la librería #include <avr/io.h>. En el datasheet utilizar el apartado 23.9.
- Usar el ADC. Para ello tenemos que poner el registro ADCSRA al valor ADEN (valor 7) según el datasheet.
ADCSRA |= 1 << ADEN; /*Habilita el ADC*/
ADCSRA &= ~(1 << ADIE); /*No habilitar la interrupción cuando termine de convertir*/
ADCSRA = ADCSRA & ~(1 << ADPS0 | 1 << ADPS1) | (1 << ADPS2) /*Eleccion del preescalado (frecuencia de muestreo)*/Necesitaremos usar el multiplexor para utilizar el ADC.
Con el fin de empezar una conversión activo el siguiente bit
ADCSRA |= 1 << ADSC;
while (ADCSRA & (1 << ADSC) != 0) /* Esperamos hasta que se vuelva 0 el bit ADSC (pooling)*/
valor = ((unsigned)ADCH & 0x03) << 8 | (unsigned)ADCL; /*El registro [ADCH ADCL] es donde se guarda el valor digital*/
ADCSRA |= 1 << ADIF; /*ADIF a 1 para terminar*/- Escribir o Leer pines digitales (en este ejemplo los del grupo D). Ir al apartado 13.4. del datasheet.
DDRD = 0xff; /*Poner a 1 todos los bits de DDRD hace que todos los pines D sean de salida*/
PORTD = valor >> 8; /*En este caso saco los 8 bits uno por cada puerto*/Con I/O Port podemos ver los valores de los puertos (está todo guardado en las direcciones más bajas de la memoria RAM).
#include <avr/io.h>
#include <avr/interrupt.h>
ISR(TIMER0_COMPA_vect)
{
ADCSRA |= 1 << ADSC;
}
ISR(ADC_vect)
{
unsigned v;
v = (unsigned)ADCL | (((unsigned)ADCH & 0x03) << 8);
}
int main(void)
{
cli();
/*14.9 del datasheet*/
TCCR0B &= ~(7<< CS00); /*Preescalado a 0 para parar el Timer*/
TIMSK0 = 2; /*Interrupción cuando Match con A*/
TCNT0 = 0; /*Contador a 0*/
OCR0A = 10;
TCCR0A = (1 << COM0A0) | (2 << WGM00); /*Hacer toggle cuando haya un match y modo CTC (los del centro hacen algo pero los quiero a 0)*/
TCCR0B &= 0x07; /*Termino CTC (los primeros me la pelan) dejando el preescalado*/
/*23.9 del datasheet*/
ADCSRA |= (1 << ADEN) | (1 << ADIE) | (3 << ADPS0); /*Es mejor no activar ADIE ahora por el tema de que la primera interrupción duraba más*/
ADMUX = (1 << REFS0) | (4 << MUX0)
sei();
TCCR0B &= ~(3<< CS00); /*Preescalado para iniciar el timer*/
while(1)
}La mayoria de CPUs actuales tienen ciertos circuitos hardware que hacen que se reduzca el tiempo medio (ACET) pero que aumente el "jitter" (es complicado controlar exáctamente el tiempo). Una CPU genérica ("más antigua") es más util para tiempo real duro debido a esa reducción de "jitter" y realiza estos pasos (cada paso está bajo el tiempo dado por un reloj):
- Obtiene las instrucciones de memoria.
- Decodifica la instrucción.
- Ejecuta la instrucción (cada instrucción tiene tiempos diferentes e incluso una misma instrucción puede tener tiempos diferentes).
- Actualiza los registros (son memorias muy pequeñas y muy rápidas dentro de la CPU) internos o de memoria.
- Se miran las interrupciones pendientes y las sirven.
En cuanto a las clases de CPUs para tiempo real suave o sistemas de media/gran escala (microprocesadores): son computacionalmente poderosas, tienen gran costo (energético, económico, de tamaño...), expone sus buses y siguen una arquitectura "Von Neumann" (memoria de datos y programa es la misma).
En cuanto a las clases de CPUs para tiempo real duro y sistemas de pequeña escala (microcontroladores): tienen poco poder computacional, tienen bajo costo (energético, económico, de tamaño...), no expone sus buses (tienen periferia) y siguen una arquitectura "Harvard" (varios tipos de memorias puden ser accesibles a la vez). Los PLCs son microcontroladores preparados para media (y a veces gran) escala y para entornos hostiles (industrias).
Las FPGAs no son CPUs, son circuitos programables (se podría fabricar una CPU con dichos componentes). Estas FPGAs son útiles para tiempo real real (tiempos mínimos) ya que creas tu el circuito a tu medida.
Los tiempos ACET y WCET se medirán en ciclos de reloj más que en segundos. Para medir los tiempos iremos a cada una de las partes del código y calcularemos el ACET y el WCET. Una vez medidos realizaremos lo siguiente:
-
Más Exacta: Saber el tiempo que tarda cada instrucción (o con simulación o con datasheet). En este datasheet vienen los ciclos de reloj de cada instrucción en el apartado "31. Instruction Set Summary".
-
Intermedia 1: Saco un 1 y un 0 por un puerto del MCU antes y despues de una instrucción respectivamente. Con un osciloscopio puedo ver el tiempo que ha tardado. La instrucción de sacar un valor por un puerto tarda un tiempo (aunque el jitter sea nulo), por lo que no es super exacto.
#include <avr/io.h>
unsigned x;
int main(void)
{
unsigned char f;
x = 8;
PORTB |= 1 << 1;
for(f=0; f < 1000; f++) x+=2;
PORTB &= ~(1 << 1);
}- Intermedia 2: Usar interrupciones que te midan tiempo. Hay varios delays en el proceso (interrupción, comandos de asignación...) y hay jitter.
# include <avr/io.h>
# include <avr/interrupt.h>
volatine unsigned long tcount;
ISR(TIMER0COMP_vect)
{
++tcount;
}
int main(void)
{
unsigned long t0, t1, measured_time;
while (1)
{
t0 = tcount;
/*código a medir*/
t1 = tcount;
measured_time = t1-t0;
}
}- Menos Exacta: Usar librerías de tiempo. Más delay y más jitter.
#include <time.h>
int main(void)
{
clock_t t0,t1;
double measured_time;
while(1)
{
t0 = clock();
/*código a medir*/
t1 = clock();
measured_time = (double)t1-t0/(double)CLOCKS_PER_SEC;
}
}Finalmente hablaremos de como medir ciertos procesos.
-
Decisiones
if: Hay un jitter porque dependiendo de si va por la ramaifo porelsetarda un tiempo u otro. Existe el tiempo de evaluar la condición$t_{cond}$ , el tiempo de saltar a entre cuerpos$t_{jump_i}$ y el tiempo del cuerpo$t_{body_i}$ . El WCET se calculará$WCET = t_{cond} + max(rama_{true}, rama_{false})$ . El ACET se calculará$ACET = t_{cond} + P_{true}*rama_{true} + P_{false}*rama_{false}$ (siendo$P_i$ la probabilidad de ir por esa rama). Los valores de${rama}_i$ son los valores ACET o WCET (depende de lo que queramos calcular) de cada rama. -
Bucles
for: Hay jitter porque depende de las condiciones, de lo que tarde cada iteración, etc. En cuanto a tiempos existen el tiempo de inicializar la variable iterativa$t_{init}$ , el tiempo de evaluar la condición$t_{cond}$ , los tiempos de saltos$t_{jump_i}$ , el tiempo del cuerpo$t_{body}$ y el tiempo de actualizar la variable iterativa$t_{iter}$ . El cálculo de WCET es$WCET = t_{init} + N * (t_{cond}+t_{bucle}) + t_{cond} + t_{jump-out}$ . El tiempo del bucle incluye el cuerpo más los saltos internos. El ACET es igual pero con tiempos ACET. -
Llamadas a rutinas: Los tiempos de la rutina son el tiempo de prólogo
$t_{prologue}$ , el tiempo de preparar las variables locales$t_{locals}$ , el tiempo del cuerpo$t_{body}$ , el tiempo de devolver el valor$t_{retval}$ , el tiempo de descartar las variables locales$t_{end-locals}$ , el tiempo del epílogo$t_{epilogue}$ y el tiempo de vuelta al programa$t_{return}$ . En cuanto a la llamada a la rutina también hay tiempos de preparar parámetros$t_{params}$ , llamar a la rutina$t_{call}$ y obtener el resultado y borrar parámetros$t_{ret}$ .
Con el fin de acelerar el código existente:
- Usa otros algoritmos para el mismo problema y compara los tiempos.
- Intenta mirar que partes del código tardan más e intenta acelerar esas partes (por ejemplo, mejor escribir 3 veces el mismo código que meterlo en un bucle).
- Crea tamblas (LUTs) con valores precalculados para que se utilicen y no los tengas que calcular tú.
- Usar coma fija en vez de coma flotante.
- Escribir parte del código en ensamblador. En c se hace de la siguiente forma.
asm volatile
(
"ldi r18, 12 ; assembly code\n\t"
"out 0x2e,r18 \n\t"
);- Usar tiempos no productivos (el programa está esperando) para realizar cálculos.
- Cambiar el reloj de la CPU.
- Cambia la CPU por una más rápida.
- Hazlo con una FPGA (hazlo con electrónica).
Con el fin de desacelerar partes del código (añadir delays).
- Tiempos pequeños con ensamblador o lineas de c no productivas.
asm volatile
(
"nop ; delay 1 CPU clock period \n\t"
);volatile unsigned p; /*volatile es para que el compilador no optimice y borre esas lineas*/
p |= 0; /*Un método*/
p++; p--; /*Otro método*/- Tiempos más grandes con bucles inútiles.
Dentro de un ordenador con sistemas operativos hay delays impredecibles. Incluso en un MCU sin S.O. también hay cosas impredecibles como:
- CPU pipeline: Mecanismo para acelerar la ejecución. Para cada instrucción hay 5 etapas y con este método se pueden hacer el paralelo haciendo que cada instrucción tarde una etapa. Sin embargo hay instrucciones que son condicionales y no se puede empezar con la siguiente instrucción hasta que se termine. Esto implica un jitter. En el MCU ATmega328P, hay dos estapas de pipeline (fetch + decode y execute + update + interr).
- Memoria Caché: Te traes una parte de la memoria RAM a la memoria de la CPU y trabajas con ello.
- Memoria Virtual: Necesita grandes tiempos y gran jitter (debido a que tiene que encontrar hueco, darselo, escribir en el disco, etc...).
- Interrupciones: Las interrupciones tienen un delay.
- Transductores: Sensores y Actuadores. La periferia de la CPU trabaja con señales eléctricas. Los transductores cambian las señales entre el dominio eléctrico y otro.
- Pooling / Busy-Waiting: Realizo un bucle que observa todo el rato si hay datos. En el caso de que existan los datos los lee.
Aunque sea muy fácil de programar, este bucle lo ejecuta la CPU. Esto significa que la CPU no puede hacer otra cosa, solo mirar si hay datos en bucle.
Por otro lado, desde que se da cuenta de que hay un dato hasta que lo lee, hay un "jitter" (retraso desde que el dato está en el exterior hasta que se obtiene).
- Muestreo de Datos guiado por Interrupciones: Si existe un dato en el exterior, se da una iterrupción asíncrona para avisarnos de que está el dato. Esto tiene un tiempo de latencia y en el momento que se trata la interrupción (con el fin de leer el dato), el prgrama principal deja de funcionar. El "jitter" se minimiza (aunque la latencia total, debido al la interrupción y al prólogo, puede ser mayor que en pooling).
La perferia de la MCU (Unidad Micro Controlador) trabaja con voltajes (en rangos medidos como $[0->5]$V o $[0->3.3]$V), no corrientes. La CPU interpreta el voltaje analógico en bits digitales.
Las salidas digitales tienen buffers (almacenan el último dato dicho por la CPU) pero las entradas no.
La periferia es una circuitería independiente a la CPU (funciona en paralelo). Ambos se comunican a través de registros específicos (como tópicos en ROS2).
En el "ATmega328P" existen 3 tipos de registros:
- Asignar entrada o Salida: Los registros DDRB, DDRC y DDRD de 8 bits cada uno (un bit por cada pin de cada zona B, C o D) son de salida o de entrada si pondremos un "1" o un "0" respectivamente.
- Leer datos externos: Los registros PINB, PINC, PIND de 8 bits cada uno (un bit por cada pin de cada zona B, C o D).
- Escribir datos al exterior: Los registros PORTB, PORTC, PORTD de 8 bits cada uno (un bit por cada pin de cada zona B, C o D).
En esta MCU,los nombres son de tipo volatile unsigned char pero el compilador los entiende como direcciones en memoria al utilizar el módulo #include <avr/io.h>.
Ejemplo de uso:
DDRB |= (1 << 2) | (1 << 4);-
|=: es similar a+=para las variables numéricas pero con la función lógica "OR"|. -
(1 << 2): desplaza un bit "1" dos hacia la izquierda (00000001->00000100).
Esta señal es muy util porque con señales digitales, depende del tiempo que esté en alta y en baja, muchos circuitos interpretan esto como una señal analógica. No se suele variar "P", lo que varía es "W".
Esta periferia es necesaria para producir estas señales. Pre-Escala el reloj de la MCU (o lo deja igual o disminuye la frecuencia) y cuenta cada pulso hasta que se llegue al número deseado (puede ser que el "counter register" sume o reste con cada pulso).
En el "ATmega328P" existen 3 tipos de timers (OC0, OC1 y OC2).
Según la imagen anterior, en esta MCU tenemos 2 acciones por cada timer (aunque ambas tienen la misma cuenta).
-
El timer OC0 es normal: Su "counter register" es TCNT0. Sus "comparison register" son OCR0A y OCROB. Sus "configuration registers" son TCCR0A y TCCR0B (Aquí "A" y "B" no tienen nada que ver con los pines).
-
El timer OC1 tiene una resolución más fina: Puede contar valores más grandes. Su "counter register" es TCNT1 y es un registro doble (16 bits). Para escribir en ese registro (2 distintas instrucciones), primero el MSB y segundo el LSB. Para leer, primero el LSB y segundo el MSB. Sus "comparison register" son OCR1A y OCR1B (funcionan igual que el TCNT1). Sus "configuration registers" son TCCR1A, TCCR1B y TCCR1C.
-
El timer OC2 usa más frecuencias: Velocidades a las que contar. Su "counter register" es TCNT2. Sus "comparison register" son OCR2A y OCR2B. Sus "configuration registers" son TCCR2A y TCCR2B (Aquí "A" y "B" no tienen nada que ver con los pines).
Los timers pueden generar interrupciones cuando se cumplen las igualdades o cuando el "counter register" se desborda.
Para códigos de repetición y timeouts utilizar el módo CTC (Clear Timer on Compare-match) de los timers con el fin de que funcionen a tiempo real. Solo se puede aplicar a los pines "A" de los timers.
Cuando el timer realiza un match, puede actuar de varias maneras (aunque siempre actuará en el siguiente instante de tiempo al match). Nosotros veremos dos:
- Toggle Output: Cambiar la salida de 0 a 1 o viceversa.
- Set Output: Poner la salida a 1.
Para la generación de señales PWMs utilizar el módo PWM Phase Correct Mode de los timers con el fin de que funcionen a tiempo real. Pone a 0 cuando sube y a 1 cuando baja.
Procedimiento:
- Incluir
#include <avr/io.h> - Parar el timer (se hace poniendo el preescalado a 0)
- Decir qué interrupciones queremos que nos avise con
TIMSK# - Resetear
TCNT# - Escribir el
OCR#@ - Sacar por pines las señales
- Configurar
TCCR#* - Cambiar el preescalado a un valor querido (en el momento que se haga, se empieza a contar).
Los voltajes que manejan los MCUs son de pequeña magnitud (máximo 5V o 3.3V). Normalmente el rango de [0V - 5V] suele dar valores discretos de [0 - 1023]. Se generan estas señales utilizando DACs. Se leen estas señales utilizando ADCs (este proceso requiere varios pasos por lo que es más lento). El ATmega328P no tiene DACs pero si un ADC que se comparte en cada canal de entrada y tarda 25 ciclos de reloj en realizar la primera conversión y 13 en las demás.
Los registros a utilizar son: ADCSRA (para configuración), el ADMUX (multiplexador que elige el canal) y el ADCH/L (dos registros que dan el valor leido).
Procedimiento
- Incluir
#include <avr/io.h> - Encender el ADC poniendo el bit
ADENdelADCSRAa 1. - Configurar el resto: Interrupciones (
ADIE), usar reloj preescalado (ADPS)... - Configurar el multiplexor: Rango en voltaje (
REFS), donde poner los 10 bits (ADLAR), elegir el canal (MUX). - Empezar la conversión con
ADSC. - Cuando termine la conversión,
ADIFse vuelve 1. - Leer
ADCL(priemero) yADCH(segundo). - Si se hace pooling, poner a 1 el
ADIFpara borrarlo.
Interrupción: Señal eléctrica que produce una pausa del programa principal. Tiene jitter (debido a que se tiene que terminar la instrucción anterior) aunque este es mínimo, y tiene un prólogo y un epílogo que aumentan la latencia. Es una rutina que es llamada de forma asíncrona sin parámetros de entrada ni de retorno (por eso son necesarias las variables globales).
Existe una tabla de interrupciones las cuales tienen prioridad unas sobre las otras, es decir, tanto si se llaman varias a la vez como si una de ellas se ejecuta y durante su ejecución se llama otra, se realizará la que mayor prioridad tenga. Esta tabla está compuesta por:
- Las direcciones de cada ISR.
- Instrucciones máquinas que salten a la ISR.
- Datos procesables para obtener la dirección a la que saltar.
Los vectores de interrupción son los índices de la tabla.
La máscara global de interrupciones (i-bit) es un bit que se guarda en el registro de estados que indica si la CPU puede servir o no interrupciones. Las máscaras individuales de interrupciones son iguales que las globales pero para cada interrupción (activar o desactivar una u otra interrupción).
La flag de interrupción individual pendiente es un bit que se activa cuando una interrupción es llamada y se borra por la CPU cuando se termina. Se pueden leer con un pooling y que no sean atendidas pero que sean borradas por el usuario escribiendo un 1.
La prioridad de las interrupciones es:
- Intrínseca (hardware): cuando la CPU se encuentra con varias peticiones de interrupción a la vez.
- Extrínseca (prioridades anidadas): se produce una interrupción cuando se está ejecutando una ISR. El usuario puede cambiar la prioridad. Normalmente al entrar en una ISR se desactiva el i-bit y no puede entrar otra, sin embargo, si queremos se puede dar estas prioridades y que se haga una pausa en una para entrar en otra con mayor prioridad.
Debido a que una interrupción se ejecuta al terminar la última instrucción, hay un jitter. Una vez terminada la instrucción, si hay alguna interrupción pendiente (y el i-bit está habilitado) se elige la que tenga una prioridad intrínseca más alta (de las que tengan su máscara individual habilitada).
Una vez elegida la interrupción a realizar se guarda la dirección de retorno en la pila de la memoria RAM, se guardan los registros de estados en la CPU, se desactiva el i-bit, miro la tabla de interrupciones para obtener la dirección de la ISR y salto a la ISR. Todo lo anterior al salto no puede durar más de 4 ciclos en el caso del ATmega328P. El salto tarda 3 ciclos en el caso del ATmega328P.
Finalmente se retorna con la instrucción máquina RETI o IRET, esta acción reinicia las flags de estado de la CPU y el i-bit, borra la flag de interrupción indiividual y se salta a la dirección de retorno que se encontraba en la pila. Esta instrucción tarda 4 ciclos de reloj.
Cuando se está realizando una ISR y llega otra interrupción (prioridades extrinsecas), esto se realiza en el código por el usuario. Dentro del cuerpo de la ISR se debe leer y guardar en variables locales el estado de la máscara individual. Se habilitan las máscaras de las interrupciones a las que queremos darle mayor prioridad. Se habilita el i-bit. Se ejecuta la ISR. Se deshabilita el i-bit. Restauramos las máscaras individuales como estaban antes. Se retorna la ISR.
Compilación paramétrica (ISR(<NOMBRE_INTERRUPCION>))
En el uso de variables globales junto a ISR es comun utilizar volatile ya que si no el código del main se podrá optimizar y borrar ciertas partes del código al creer que son inútiles.
El uso de variables atómicas es útil en cuanto a las interrupciones. Por ejemplo, si se trata una variable de 16 bits, se tratan primero 8 bits y luego otros 8 bits. Entre ambos tratos se puede llamar a una ISR que cambie dicha variable y estropee el segundo tratamiento. Si utilizamos variables atómicas, es decir, de menos de un bus (un byte en ATmega328P) esto no ocurre. Una variable atómica significa que es su uso va a ser correcto lo uses como lo uses.
Con el fin de calcular el WCET/ACET de una ISR es muy parecido a una rutina. La latencia
Como las ISRs son un poco impredecibles, no deberían tener mucho código.
En el ATmega328P las direcciones de las ISRs están al principio de la memoria de programa.
Es necesaria la librería #include avr/interrupt.h. Está todo en el apartado 12 del datasheet.
Tiene más prioridad cuanto menor es la diercción de programa. Para escribir la ISR se pone el nombre puesto en "Source", quitando los espacios y añadiendo _vect.
En cuanto a la configuración, las máscaras individuales se encuentran en los bits EIMSK (interrupción externa), TIMSK* (Timer *) y ADCSRA (ADC)
Las flags de interrupciones pendientes están en EIFR, TIFT* y ADCSRA.
Si queremos elegir si la interrupción es por nivel o por flanco EICRA.
Utilizando cli() y sei() habilitan y deshabilitan el i-bit.
El objetivo de esta parte es entender el diseño de sistemas informáticos para el control de sistemas mecatrónicos para tiempo real
Los componentes se relacionan entre sí de forma material (procesos físicos, mecanismos...) donde el paso del tiempo es significativo. Puede tener un modelo que prediga su futuro y puede incluir interacciones con personas.
Si estos procesos están controlados por un procesador, entonces hablamos de Sistemas Informáticos de Tiempo Real (SITR).
Características de un SITR
- Interfaz hombre-máquina: El operario usa los periféricos.
- Monitorización y Parametrización.
- Modelo Computacional del Sistema de Control.
- Instrumentación: Tiempos de Respuesta acotados.
- Sistema fisico de tiempo real.
Requisitos de un SITR
- Selección del HW/SW para tener un coste razonable.
- Representar el comportamiento temporal del sistema.
- Considerar el tiempo de la traducción del lenguaje al código máquina.
- Máximizar la flexibilidad y la tolerancia a fallos.
- Diseñar los test y elegir los equipos de prueba.
- Aplicar los sistemas abiertos disponibles.
- Medir los tiempos de respuesta y analizar el rendimiento para que se cumplan los requisitos temporales.
Tipos de STR
- Soft-RT: El rendimiento del sistema se degrada cuando no se cumple el "deadline".
- Hard-RT: El rendimiento es catastrófico cuando no se cumple el "deadline".
Concéptos Erróneos de los STR
- Sistema de tiempo real es igual a sistema rápido
- En análisis rate-monotonic ha resuelto el problema de los STR.
- Hay metodologías universales para diseñar SITR.
- No hay necesidad de desarrollar nuevos SOTR (Sistemas Operativos de Tiempo Real) porque hay muchos productos disponibles en el mercado.
- El estudio de los SITR es teoría de planificación.
-
Secuenciales (PseudoKernels): Bucle de control por sondeo (poolled loop, super loop). Son los más sencillos y los más deterministas.
- Características:
- Recomendado para sistemas sencillos.
- Factible sobre un HW muy básico.
- No explota el paralelismo intrínseco de la aplicación.
- No aísla las diferentes actividades.
- Espera activa (consumo).
- Desventajas:
- Aunque se sincronice fácilmente con el temporizador del sistema: es dificil de implementar si un sistema necesita varios tiempos de ciclo diferentes, se deben dividir las funciones que excedan el tiempo de ciclo y complican el SW (dificil de entender).
- Un cambio simple puede tener efectos colaterales impredecibles, multiplicando el tiempo de análisis y depuración.
- Características:
-
Dirigido por Interrupciones: Requieren soporte HW de interrupciones, pequeña sobrecarga por cambio de contexto, existen híbridos (incluyen una tarea de fondo) y tienen concurrencia sencilla sin SO.
- Características:
- Las rutinas de interrupción requieren soporte HW para gestionar interrupciones y aíslan unas actividades de otras.
- El bucle de espera (segundo plano) realiza una espera activa y pone al procesador en modo bajo consumo (realiza tareasque no requieran tiempo real).
- Desventajas:
- Las operaciones críticas se procesan dentro de una Rutina de Tratamiento de Interrupción (RTI): Las RTI se hacen complejas con largos tiempos de ejecución y el anidamiento de RTI puede dar lugar a tiempos impredecibles y un mayor requereminto de espacio en la pila.
- El intercambio de datos entre el bucle principal y las RTI es a través de variables globales compartidas.
- Características:
-
Concurrentes: Para sistemas de media y gran escala, necesitan soporte para multiprogramación, conlleva una sobrecarga para la gestión de tareas y utiliza SOTR.
- La aplicación se divide en tareas autocontenidas.
- Utiliza políticas para la planificación de su ejecución.
- Se requiere un mecanismo para el intercambio de tareas: normalmente programado en ensamblador debido a que depende de la arquitectura del procesador.
- Es necesario proveer mecanismos de sincronicación y comunicación entre tareas.
- Las tareas deben poder acceder a temporizadores.
- Permite diseño modular.
- Multiprocesamiento: destion de procesos y threads (creación, terminación, prioridad...)
- Comunicación entre procesos y tareas (IPC): Señales, semáforos, memoria compartida, colas de mensajes...
- Dificultades con la sincronización: exclusión mutua, interbloqueo, inaniciń (inversión de prioridad)...
- Necesita un siporte para la ejecución de procesos o threads en tiempo real (SOTR)
- El componente que proporciona el SOTR es el planificador de tiempo real (scheduler): Elige qué proceso de la cola de listos se despacha, cuándo y en qué condiciones se expuñsa un proceso en ejecución (expropiación)...
-
Implementación de SITR
- En los SITR embebidos sencillos: la aplicación ejecuta las funciones en orden fijo, las RTI se usan para respuestas críticas en tiempo del programa y es buena solución para sistemas pequeños (liitaciones para aplicaciones más complejas).
- Es posible crear un SITR sin un SOTR mediante ejecución de una o más funciones en un bucle de control (pseudokernel + RTI). Sin embargo, hay muchos asuntos relacionados con la planificación, la temporización y el mantenimiento y el SOTR se encarga de ello (liberando al programador).
El SO es un programa que controla la ejecución de los programas de aplicación. Actúa como interfaz etre las aplicaciones y el hardware.
Los objetivos del SO son hacer que el ordenador sea más cómodo de usar, usar los recursos del sistema de forma eficiente y conseguir que el desarrollo efectivo, testeo e introducción de nuevas funcionalidades no interfiera con el servicio.
El SO proporciona:
- Acceso al sistema: Usuarios, grupos, permisos...
- Ejecución de programas: Colas de batch, planificación, privilegios
- Acceso a dispositivos de entrada/salida: interfaces red, discos, periféricos...
- Controlar el acceso a los ficheros: permisos, accesos concurrentes...
- Herramientas para el desarrollo de programas: editores, compiladores y depuradores (debuggers).
- Contabilidad: realizar estadísticas, monitorizar el rendimiento, anticipar futuras mejoras, usado para cobrarle a los usuarios.
- Detección y respuesta a errores: Errores HW interos y externos (errores de memoria, fallo en un dispositivo...), errores SW (overflow, acceso prohibido a direcciones de memoria...) y el SO puede no conceder la petición de una aplicación.
Gesión de Recursos del SO:
- Funciona igual que cualquier otro programa del ordenador.
- Renuncia al control del procesador para que lo usen otros programas (depende del procesador para volver a tomar el control)
- No realiza trabajo neto, solo dirige al procesador en el uso de recursos y en la temporización de otros trabajos.
- Gestiona procesos, recursos y memoria, y proteje la información.
Procesos:
- Es un programa en ejecución.
- Una instancia de un programa ejecutando en un procesador.
- La entidad que puede ser "asignada a" o "ejecutada en" un procesador.
- Una unidad de actividad caracterizada por un hilo secuencial de ejecución, un estado actual y un conjunto de recursos asociados.
Gestión de la Memoria:
- Aislar procesos.
- Gestión y asignación automática.
- Soporte para programación modular.
- Protección y control de acceso.
- Almacenamientod de larga duración.
Memoria Virtual:
- Permite al programador direccional la memoria desde un punto de vista lógico.
- Evita que haya un vacío entre el desalojo de un proceso y la carga del siguiente.
- Necesita soporte HW.
- El HW y el SO proporcionan al usuario un "procesador virtual".
- En procesos de TR se puede evitar que sus páginas abandonen la memoria física.
Sistema de Ficheros:
- Sistema de memoria persistente: permanece aunque se apague el sistema.
- La información se almacena en objetos con nombres llamados ficheros.
- Implementa el almacenamiento de larga duración (long-term).
Planificación y Gestión de Recursos:
- Justicia: Proporcionar un acceso igual y justo a todos los procesos de la misma clase (prioridad)
- Respuesta Diferencial: discriminar entre diferentes clases de trabajos (prioridad)
- Eficiencia: Maximizar la productividad, minimizar el tiempo de respuesta y acomodar al mayor número de usuarios posible.
Sistemas Operativos de Tiempo Real (SOTR):
- Es de TR si es capaz de dar un soporte a tiempo (tiene que sear reactivo, tener consciencia del tiempo...): Son creados explícitamente para ser SOTR o son SO modificados.
- Puede dar soporte de Hard-RT o de Soft-RT.
- Incluye características nuevas a los SO y propias de SITR.
- Excluye facilidades innecesarias en estos sistemas para hacerlos más pequeños simples y rápidos disminuyendo los requerminientos (CPU, memoria, consumo...).
- Cambios rápidos de procesos/threads (context switch).
- Pequeño tamaño (funcionalidad mínima y suficiente).
- Respuesta rápida a interrupciones externas (interrupt delay).
- Multitarea y soporte IPC, sincronización, señales...
- Planificación expropiativa basada en prioridades.
- Reducción de los intervalos en los que están inhabilitadas las insterrupciones.
- Primitivas para detener, reanudar y demorar tareas.
- Alarmas especiales y temporizadores.
- Archivos secuenciales para acumular datos a alta velocidad.
Respuesta a un Evento en un SO:
- Ocurre la interrupción externa (hay posibles retrasos por prioridad o ISRs deshabilitadas...)
- Cambio de modo/contexto.
- Ejecución de la RTI del kernel.
- Ejecución del manejador de interrupción del driver: el manejador debe hacer su trabajo y retornar tan pronto como sea posible. Si hay mucho trabajo no se puede hacer aquí dentro.
- Si el proceso debe despertar a un proceso bloqueado: Tras la ejecución de la RTI el SO debe ejecutar el planificador. Se cambia el modo/contexto (proceso activado) y el proceso se bloquea dentro de una llamada al sistema (ésta debe finalizar y retornar)
- Es dificil para un SO cumplir con el estándar POSIX y dar con una respuesta a un evento en TR.
Qué queda fuera del SO:
- Soporte para multitud de periféricos, tarjetas exp...: Tarjetas de sonido, gráficas y lectores de CD/DVD, manejo de controladores más sencillo (drivers)...
- Soporte para gestión de usuarios/multiusuario: En dispositivos embebidos, control de acceso, encriptación...
- Soporte para memoria virtual: En sistemas que pueden carecer de disco duro, en SOTR con memoria virtual, capacidad para desactivar la paginación en la aplicaciones de TR.
Definiciones:
- Módulo: La colección de objetos y operaciones relacioneadas a nivel lógico.
- Encapsulación: Técnica de aislamiento de una función dntro de un módulo (ocultación de la información, compilación separada, tipos abstractos de datos).
- El ingeniero de software decide como se descomponen los sitemas grandes en módulos.
Ocultación de la Información:
- Una estructura modular permite visibilidad reducida: permite que parte de la información quede oculta en el interior del módulo.
- La especificación y el cuerpo del módulo se pueden suministrar por separado.
- La especificación debería ser compilada sin que el cuerpo esté escrito.
- En C, los módulos no están bien formalizados.
- Los programadores utilizan un archivo
.hpara la interfaz y un archivo.cpara el cuerpo. - Los móduos no son entidades de primer nivel en los lenguajes de programación.
Tipos de datos abstractos
- Un módulo puede definir tanto un tipo de dato como las operaciones sobre dicho tipo.
- Los detalles sobre el tipo deben estar ocultas.
- Hay que declarar los tipos. Las funciones reciben instancias de los tipos como parámetros.
- En C, los parámetros se pasan como punteros para garantizar que el usuario no tiene conocimiento de los datos internos del tipo. En el archivo
.hse proporciona una declaración incompleta del tipo.
typedef struct queue_t *queue_ptr_t;
queue_ptr_t create();
int empty(queue_ptr_t Q);
void insertE(queue_ptr_t Q, element E);
void removeE(queue_ptr_t Q, element *E);Resumen:
- Los módulos soportan: ocultación de información, compilación separada y tipos de datos abstractos.
- C tiene una estructura de módulos estática.
- C no tiene un soporte formal de módulos.
- La compilación separada permite la construcción de librerías de componentes precompilada.
- La descomposición de un programa de gran tamaño en módulos es la esencia de la programación de sistemas grandes.
- El uso de tipos abstractos de datos o programación orientada a objetos, ofrece una de las herramientas principales que los programadores pueden utilizar para gestionar grandes sistemas de software.
La concurrencia es el nombre dado a las técnicas de programación para representar el paralelismo potencial y resolver los problemas de sincronización y comunicación.
La programación concurrente es importante porque proporciona un marco en el que estudiar el paralelismo sin empantanarse en la implementación.
La implementación del paralelismo es un tema de ingeniería del computador (HW y SW) que es independiente de la programación concurrente.
Es necesario ya que permite poder usar más de un ordenador para resolver un problema (operando en paralelo). Se crea un programa secuencial que maneja las diferentes actividades concurrentes. Esto complica la descomposición del problema.
Terminología:
- Un programa concurrente es una agrupación de procesos autónomos que se ejecutan (lógicamente) en paralelo. Cada proceso tiene una secuencia de ejecución.
- Tipos de Implementación
- Multiprogramación: Un unico procesador realiza varios procesos (se multiplexan los procesos)
- Multiprocesamiento: Se utiliza un multiprocesador que tiene acceso a memoria distribuida.
- Procesamiento Distribuido: Hay varios procesadores que no comparten memoria.
Procesos y Threads:
- Un proceso es un programa en ejecución. Todos los SO pueden crear procesos.
- Cada proceso tiene su memoria aislada (memoria virtual). Un proceso A no puede leer ni cambiar la memoria de un proceso B.
- Un thread (hilo) es una subtarea dentro de un proceso (al abrir chrome, un hilo muestra la página, otro descarga el archivo, otro reproduce un video...). Todos los hulos comparten la misma memoria (no tienen memoria aislada). Los KLT (Kernel Level Threads) los administra el SO y los ULT (User Level Threads) el SO ni siquiera saben que existen y se gestionan desde una librería de lenguaje.
- Un thread puede leer o modificar cualquier dato del proceso (tienen acceso ilimitado a la memoria virtual del proceso).
- Como dos threads pueden cambiar la misma variable a la vez, hay que proteger el acceso a los datos compartidos. Esto se hace con mecanismos como "mutex", "semáforos", "variables de condición", "monitores"...
- Hay un debate si el lenguaje (C, Java...) debe incluir herramientas de concurrencia o es mejor dejárselo al SO.
Programación:
Cuando ejecutamos un programa este programa se convierte en un proceso. Este proceso inicial se llama "proceso padre".
- Fork: El "proceso padre" puede crear otros procesos ("procesos hijos") utilizando dicho comando. El "proceso hijo" tiene el mismo código que el proceso padre y se empieza a ejecutar a partir del comando
fork()en paralelo al "proceso padre". El hijo copia el valor de las variables del padre, aunque a partir de su creación, la memoria de cada proceso es aislada.
printf("A\n");
fork();
printf("B\n");
/*El resultado será A B B (siendo el segundo "B" impreso por el "proceso hijo")*/Si el "proceso padre" realiza el comando pid_t pid_hijo = fork(); obtiene el ID del "proceso hijo" que acaba de crear. Si un hijo realiza el comando, el ID obtenido será 0. Cuando se realiza el fork() primero se copian todas las variables del padre al hijo, se crea el hijo y después se le asigna el valor a la variable pid_hijo. Esto quiere decir que aunque el hijo copie todas las variables del padre, la variable pid_hijo no tendrá el mismo valor que el padre porque primero se han copiado, luego se ha creado el hijo y después se ha asignado el valor de esa variable.
Un proceso puede conocer su ID con el comando pid_t mypid = getpid();.
El comando exit(0) termina inmediatamente el proceso que lo ejecuta.
Para que un hijo realice un proceso hay varias opciones.
for (int i = 0; i < 3; i++) {
pid_t pid = fork();
if (pid == 0) {
// ESTO lo ejecuta CADA hijo según su valor de i
printf("Soy el hijo %d con PID %d\n", i, getpid());
if (i == 0) { tarea_del_hijo_0(); }
if (i == 1) { tarea_del_hijo_1(); }
if (i == 2) { tarea_del_hijo_2(); }
exit(0); // IMPORTANTE para evitar que el hijo siga creando hijos
}
}pid_t pid = fork();
if (pid == 0) {
pid_t yo = getpid();
if (yo % 2 == 0) {
tarea_par();
} else {
tarea_impar();
}
}pid_t hijos[3];
for (int i = 0; i < 3; i++) {
hijos[i] = fork();
if (hijos[i] == 0) {
ejecutar_tarea(i);
exit(0);
}
}pid_t pid1 = fork();
if (pid1 == 0) {
tarea1();
exit(0);
}
pid_t pid2 = fork();
if (pid2 == 0) {
tarea2();
exit(0);
}
pid_t pid3 = fork();
if (pid3 == 0) {
tarea3();
exit(0);
}pid_t pid = fork();
if (pid == 0) {
execl("/bin/ls", "ls", "-l", NULL); // hijo ejecuta ls
}- Pthread_Create: Este comando crea threads dentro de un proceso que comparten todos la misma memoria (son más ligeros que la creación de un proceso). Cada thread tiene atributos, datos que controlan como se controla el thread (tamaño de pila, política de planificación, prioridades...). En POSIX estos atributos se guardan en una estructura especial.
Los threads se crean dandole el ID del thread, los atributos, una función creada en el programa y los parámetros de dicha función. Estos parámetros son variables las cuales el thread va a poder observar sus cambios en todo momento al compartir la misma memoria que el proceso.
#include <pthread.h> /* Librería POSIX Necesaria */
pthread_attr_t attributes; /* Estructura de Atributos */
pthread_t xp, yp, zp; /* Variables que guardan los IDs*/
void controller(int *dim)
{
while (1)
{
move_arm(*dim);
}
}
}
int main()
{
PTHREAD_ATTR_INIT(&attributes); /* Valores default de los atributos*/
int X = obtenerX();
int Y = obtenerY();
int Z = obtenerZ();
/* Creación de 3 threads que utilizan la misma función */
PTHREAD_CREATE(&xp, &attributes, (void *)controller, &X);
PTHREAD_CREATE(&yp, &attributes, (void *)controller, &Y);
PTHREAD_CREATE(&zp, &attributes, (void *)controller, &Z);
PTHREAD_JOIN(xp, &result); /* Bloquea el proceso hasta que se terminen los threads*/
exit(-1) /* Devolvería un error ya que la función "controller" es un bucle y los threads no deberían terminar*/
}- Debe la concurrencia están en el SO o en el lenguaje
- A favor de estar en el SO: Es más facil componer aplicaciones multiproceso desarrollando en el lenguaje que mejor se adapte a sus requerimientos, hay estándares de concurrencia (POSIX) que cumplen muchos SO.
- A davor de estar en el lenguaje: Los programas son más legibles y mantenibles, un sistema embebido puede no tener SO, para SOs diferentes puedes llevar el mismo programa.
Explicación del código:
- El proceso padre obtiene un número
printf("[PARENT SERVER] Give me a number: ");
fflush(stdout);
n = scanf("%d", &number);- El padre crea un hijo
pid = fork();- El hijo hace calculos con el número y termina.
switch (pid)
{
case 0: // CHILD PROCESS on successful fork()
{
/* CUERPO */
return (EXIT_SUCCESS);
break;
}
}- El padre espera a que el hijo termine y se repite el bucle.
switch (pid)
{
default: // PARENT PROCESS on successful fork()
{
pid2 = waitpid(pid, &status, 0);
/* CUERPO */
}
}Posibles Casos de Error: En este caso si el proceso hijo tiene un error, el hijo muere pero el padre sobrevive obteniendo una señal de WIFSIGNALED.
Si el número es 0, el hijo no puede dividir.
result = N / number;Si el número es -1, el hijo devuelve un error.
if (ZERO_PAGE_KEY == number)
{
printf("\n[CHILD PROCESS %d] accesing through NULL pointer\n", pid);
*c = 0;
}Si el número a introducir es una letra (n = 0), el padre no crea al hijo y repite el bucle.
n = scanf("%d", &number);
if (1 == n)
{
pid = fork();
/* CUERPO */
}
else
{
scanf("%*[^\n]%*c"); // clear stdin
fprintf(stderr, "[PARENT SERVER] Not a valid number!\n");
}Explicación del código:
- El thread principal obtiene un número
printf("[main thread]: Give me a number: ");
fflush(stdout);
n = scanf("%d", &number);- El hilo principal crea un thread (función
void *worker(void *argv)) con atributos nulos y el número como parámetro.
status = pthread_create(&th1, NULL, worker, (void *) &number);- El thread "worker" obtiene el número hace cálculos con el número y termina
void *worker(void *argv)
{
int local_number = *((int *) argv);
/* CUERPO*/
pthread_exit(NULL);
}- El thread principal espera a que el thread "worker" termine y se repite el bucle
pthread_join(th1, NULL);Posibles Casos de Error: En este caso si el thread "worker" tiene un error, el proceso completo muere.
Si el número es 0, el thread "worker" no puede dividir.
local_result = N / local_number;Si el número es -1, el thread "worker" devuelve un error.
if (ZERO_PAGE_KEY == local_number)
{
printf("\t\t\t[Worker 0x%08lx]: accesing through NULL pointer\n", (long) tid);
*c = 0; // c points to NULL, trying to write into invalid page!
}Si el número a introducir es una letra (n = 0), el thread principal no crea al thread "worker" y repite el bucle.
n = scanf("%d", &number);
if (1 == n)
{
status = pthread_create(&th1, NULL, worker, (void *) &number);
/* CUERPO */
}
else
{
scanf("%*[^\n]%*c"); // clear stdin
fprintf(stderr, "[main thread]: Not a valid number!\n");
}Explicación del código:
- El thread principal obtiene un número
printf("[main thread]: Give me a number: ");
fflush(stdout);
n = scanf("%d", &number);- El thread principal crea un thread (función
void *worker(void *argv)) con atributos nulos y el número como parámetro.
status = pthread_create(&th1, NULL, worker, (void *) &number);- El thread "worker" obtiene el número hace cálculos con el número y termina
void *worker(void *argv)
{
int local_number = *((int *) argv);
if (-1 == local_number)
{
/* CUERPO*/
}
else if (CORRUPT_KEY == local_number)
{
/* CUERPO*/
}
else
{
/* CUERPO*/
}
pthread_exit(NULL);
}- El thread principal llama a la función
void calling_something(int *b)y espera a que el thread "worker" termine y se repite el bucle
calling_something(&number);
printf("[main thread]: returned from subroutine\n");
pthread_join(th1, NULL);- La función
void calling_something(int *b)cmabia los valores de las variables globalesglobalp1yglobalp2.
void calling_something(int *b)
{
int a;
/* Get memory addresses surrounding the return stack of this call */
globalp1 = &a; /* First local variable (on stack, *below* return addrress) */
globalp2 = b; /* Argument (&number) points on the stack *above* the return address */
/* CUERPO */
}- En el caso de que en el thread "worker" tengamos la condición
else if (CORRUPT_KEY == local_number), se cambiarán todos los valores de la memoria entreglobalp1yglobalp2. Esto nos muestra como distintos threads atúan sobre la misma memoria.
printf("\t\t[Worker 0x%08lx]: overwriting stack of main thread\n", (long) tid);
for (stackp = globalp1; stackp < globalp2; stackp++)
*stackp = 0;Posibles Casos de Error: En este caso si el thread "worker" tiene un error, el proceso completo muere.
Si el número es 0, el thread "worker" no puede dividir.
local_result = N / local_number;Si el número es -1, el thread "worker" devuelve un error.
if (ZERO_PAGE_KEY == local_number)
{
printf("\t\t\t[Worker 0x%08lx]: accesing through NULL pointer\n", (long) tid);
*c = 0; // c points to NULL, trying to write into invalid page!
}Si el número a introducir es una letra (n = 0), el thread principal no crea al thread "worker" y repite el bucle.
n = scanf("%d", &number);
if (1 == n)
{
status = pthread_create(&th1, NULL, worker, (void *) &number);
/* CUERPO */
}
else
{
scanf("%*[^\n]%*c"); // clear stdin
fprintf(stderr, "[main thread]: Not a valid number!\n");
}Si el número es -2, el thread "worker" elimina de la memoria la dirección de retorno de la función void calling_something(int *b). Por eso la función no puede realizar el return y da error.
void calling_something(int *b)
{
globalp1 = &a; /* First local variable (on stack, *below* return addrress) */
globalp2 = b; /* Argument (&number) points on the stack *above* the return address */
/* CUERPO */
return; // access to return address on stack (if still available)
}10000 Procesos: 2.028806 seconds
10000 Threads: 0.284165 seconds
-
Sincronización: Cómo se entrelazan las acciones de los procesos restringiendose unas a otras, por ejemplo, una acción de un proceso solo ocurre tras la acción en otro. (Si un buffer está lleno, el productor no puede asignar datos. Si un buffer está vacío, el consumidor no puede obtener datos).
-
Comunicación: Paso de información entre procesos
Ambos conceptos están relacionados ya que la comunicación requiere sincronización y la sincronizacion puede ser consideada comunicación sin contenido.
Compartir variables entre procesos es poco fiable ya que, por ejemplo, si dos procesos se comunican cambiando el valor de x, cada proceso tiene que cargar el valor de x (load), asignarle un valor y guardar dicha variable en memoria (store). Estas son 3 instrucciones que no se realizan de forma atómica y podrían solaparse en ambos procesos.
En este código hay dos procesos que se ejecutan de forma concurrente 1000000 de veces cada uno. Un proceso incrementa una variable y otro la decrementa. El valor de esa variable debería ser 0, sin embargo:
Estas instrucciones (como muchas otras) deben realizarse seguidas (de manera secuencial y no concurrentemente con otras instrucciones de otros procesos). Estas secuencias se llaman "Secciones Críticas" y la sincronización necesaria para que las secciones críticas funcionen correctamente se llama "Exclusión Mutua". Esto hará que esa parte del código se realice de forma atómica.
- Espera Activa
Se utilizan "flags" compartidas que cada proceso comprueba antes de realizar una acción y activa para que otro proceso realice algo.
Funciona bien en cuanto a sincronización pero no hay métodos simples de exclusión mutua (es complicado hacer código secuencial atómico).
Estos algoritmos generalmente son ineficientes ya que un proceso se mantiene en bucle (aunque no tenga nada que hacer) comprobando la flag y gastando CPU.
- Semáforos
Es una variable entera S no negativa a la cual solo se puede acceder mediante instrucciones P (wait) y V (signal/post).
Al ejecutar una instrucción P (wait) y (S > 0) se realiza la instrucción S--. En el caso de que (S == 0) el proceso se quedará bloqueado hasta que otro proceso incremente S (en ese caso el proceso actual se desbloqueará, realizará S-- y seguirá con la siguiente instrucción).
Al ejecutar una instrucción V (signal) se realiza la instrucción S++.
Estas instrucciones son atómicas.
En el siguiente caso, si el semáforo mutex empieza con el valor 1, cuando se ejecute el statemente Y, el semáforo tendrá el valor de 0 (debido al wait(mutex)) por lo que el otro proceso, cuando termine el statement A se quedará bloqueado en la sentencia wait(mutex) hasta que el proceso P1 realice la acción signal(mutex) (haciendo que el semáforo tenga el valor de 1).
En este código se crea un semáforo con la variable pthread_spinlock_t lock;. Esta variable tiene que ser parte de una estructura que tenga el dato al cual queremos realizar la exclusión mutua.
typedef struct {
int data;
pthread_spinlock_t lock;
} protected_data_t;
protected_data_t *p;Finalmente se realiza la instrucción P (wait) con pthread_spin_lock(&p->lock) y la instrucción V (signal) con pthread_spin_unlock(&p->lock);.
pthread_spin_lock(&p->lock);
p->data++; // <- use of shared data (critical section)
pthread_spin_unlock(&p->lock);Esto tiene inconvenientes, por ejemplo el deadlock (que ambos procesos se queden bloqueados en la sentencia wait esperando a que uno despierte al otro, sin embargo, al estar bloqueados no pueden), o el livelock (ambos procesos se quedan en un bucle esperando a que alguien cambie un flag, sin embargo, dentro del buble no hay sentencias de cambiar el flag.)
Existen semáforos binarios y genericos (contadores)
Utilizando POSIX tenemos varias instrucciones para realizar la Exclusión Mutua (mutex)
En este código, se crean 20 procesos los cuales se va a ir ejecutando de uno en uno en orden (y hasta que no termine uno no se ejecutará el siguiente).
Se debe pensar en mutex como un buffer en el cual van entrando procesos y se van poneindo a la cola. Cuando alguien realiza el unlock, se despierta el primero de la cola.
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; /* Creación mutex como variable global*/
/* En el cuerpo de la función thread*/
{
pthread_mutex_lock(&mutex); /*Bloqueo para que al pasar un thread los demás se queden esperando en cola*/
/* Cuerpo de la Sección Crítica*/
pthread_mutex_unlock(&mutex); /*Desbloqueo del mutex para que pase el primero de la cola*/
}En este código, se cambia el mutex por un semáforo de capacidad 3 el cual dejará entrar en la sección crítica procesos de 3 en 3. Cuando termine un proceso se introducirá el siguiente de la cola, sin embargo, solo podrá haber como máximo 3 procesos en la sección crítica.
Recordar que el semáforo es un valor entre 0 y 3. Con la intstrucción sem_wait se resta un valor al semáforo o se queda el thread bloqueado en cola. Con la instrucción sem_post se incrementa el valor del semáforod dejando pasar al siguiente de la cola.
sem_t capacity; /* Creación del semáforo como variable global*/
sem_init(&capacity, 0, 3);
/* En el cuerpo de la función thread*/
{
sem_wait(&capacity); /*Se bloquean threads en la cola de "capacity" hasta que el semáforo sea mayor a 0*/
/* Cuerpo de la Sección Crítica*/
sem_post(&capacity); /*Incremento el semáforo para dejar pasar al siguiente de la cola*/
}En este código cada proceso gastará un número entre el 1 y el 3 de "energía". El número máximo de "energía" es 5 por lo que se podrán meter tantos procesos como gasten la "energía" (5 procesos de 1 de "energía", 1 proceso de 3 y otro de 2 de "energía"...)
En este ejemplo hay 2 colas en las que se esperan los procesos, mutexy cond.
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_cond_t cond = PTHREAD_COND_INITIALIZER;
int available = 5;Sabiendo que la función cond_evaluation comprueba si queda "energía" disponible para que el proceso pueda entrar, en el caso de que haya "energía" suficiente ocurre lo siguiente: Se cierra el mutex para evaluar la condición de uno en uno, al haber "energía" suficiente este proceso entra en la Sección Crítica por lo que queda menos "energía" disponible (se resta). Ahora se permite que el siguiente proceso evalúe la condición (con el nuevo valor de available). El proceso realiza la sección crítica y al terminar hay más "energía" disponible (se suma).
En el caso de que no haya suficiente "energía" el procesos se queda en la cola de cond y libera el mutex para que el siguiente proceso pueda evaluar la condición (esto permite que si hay poca "energía", un proceso de 3 de no pueda entrar pero que si el siguiente proceso gasta 1, sí pueda entrar). Finalmente, cuando un proceso termina la sección crítica libera al primero de la cola de cond (utilizando pthread_cond_signal) para que vuelva a evaluar la condición para ver si hay suficiente "energía" o no.
{
pthread_mutex_lock(&mutex);
while (!cond_evaluation(n, available, required)) pthread_cond_wait(&cond, &mutex);
available -= required;
pthread_mutex_unlock(&mutex);
/* Cuerpo de la Sección Crítica*/
pthread_mutex_lock(&mutex);
available += required;
pthread_cond_signal(&cond);
//pthread_cond_broadcast(&cond); // smaller ones overtake biggers
}Hay muchos comandos de este tipo que se encuentran en este pdf.
- Lectores y Escritores: Hay que tener en cuenta que infinitos lectores pueden leer la misma variable a la vez pero solo un escritor puede escribir en una variable de forma secuencial (no puede haber concurrencia). Ni si quiera un lector puede leer una variable mientras un escritor está escribiendo en ella.