Tareas estáticas en FreeRTOS para Arduino

Tareas estáticas en FreeRTOS para Arduino

Ya en la entrada anterior platicábamos de cúando y porqué utilizar un sistema operativo de tiempo real en nuestras aplicaciones (si no la has leído, te recomiendo hacerlo antes de continuar), y de que FreeRTOS tiene dos maneras de crear tareas. Platicamos largo y tendido de la creación de tareas dinámicas y de un montón de conceptos necesarios.

Hoy te voy a mostrar cómo crear tareas estáticas en el sistema operativo FreeRTOS para tus programas en Arduino UNO (y compatibles), e intentaré explicarte qué son, cómo se crean y porqué debes utilizar tareas estáticas siempre que te sea posible. También te darás cuenta de porqué en la entrada anterior te dije que crear tareas estáticas necesita de más pasos. Pero créeme, vale la pena utilizarlas.

Si ya leíste la entrada anterior verás que estaré repitiendo algo de la teoría ya vista. Lo hago así por aquellos lectores que no lo han hecho, y creo que te servirá como recordatorio.

Tabla de contenidos

¿Qué hay de malo con las tareas dinámicas?

Lo único malo son las llamadas a las funciones malloc() y free() que nos lleva a dos problemas críticos en los sistemas embebidos:

  • Fragmentación de la memoria.
  • No son deterministas.

Fragmentación de la memoria

Cuando un programa llama de forma repetida a las funciones malloc() y free() la memoria RAM comienza a fragmentarse; esto es, los bloques de memoria del heap cada vez se hacen más pequeños y dispersos, llegando al punto en el que la función malloc() ya no puede encontrar un bloque de memoria contiguo del tamaño requerido, y por tanto, falla.

¿Te imaginas que un circuito electrónico de sensado, que utiliza FreeRTOS, y que has instalado en la copa de un árbol en medio de la selva falle debido a que malloc() no pudo atender tu petición de memoria? ¡Yo tampoco!

No son deterministas

El siguiente problema con las llamadas a las funciones malloc() y free() es que el tiempo que necesitan para entregar un resultado es muy variable; esto es, una primer llamada a malloc() puede tardar X ticks, pero una segunda llamada quizás tarde 1.5X ticks, y una más 0.8X ticks, y así sucesivamente. Y ambas funciones son bloqueantes, es decir, nadie puede hacer nada hasta que terminen. Esto podría provocar que tu sistema pierda eventos críticos.

Existe un tercer problema relacionado con free(). Cuando tú mandas llamar a esta función, la memoria no se devuelve de manera inmediata, solamente se marca para ser devuelta. La verdadera devolución se lleva a cabo dentro de una tarea del sistema operativo llamada IDLE task (de la cual te hablaré más adelante) que si no se ejecuta (debido a que tus tareas están activamente ejecutándose), entonces esa memoria no es devuelta.

¿Podemos seguir usando tareas dinámicas?

Claro que sí. Los que saben del tema nos recomiendan crear las tareas (y en general, todos los objetos dinámicos del programa) al inicio de la aplicación, y nunca borrarlos.

¿Qué es y qué necesito?

Al igual que en la entrada anterior vas a necesitar una tarjeta Arduino UNO o compatible junto con el sistema operativo FreeRTOS, y este paso ya lo hice por tí. Descarga el framework KleOS para tu computadora (actualmente sólo está disponible para Linux, pero lo estará para Windows muy pronto), descomprime el archivo en el lugar de tu preferencia, entra a la carpeta arduino-1.8.xx (al día de hoy xx=13), y finalmente arranca la IDE dando click en el archivo arduino (para Linux) o arduino.exe (para Windows). No debes instalar nada. En este mismo directorio encontrarás las instrucciones para compilar programas en la IDE o en la consola (README.pdf).

Recuerda que cuando escribes aplicaciones basadas en un sistema operativo, éstas deben ser descompuestas en tareas (o procesos. Aunque en la literatura el término más común es proceso, en FreeRTOS se le dice tarea, task en inglés). Crear tareas en FreeRTOS requiere algunos pasos:

  1. Escribir el código de las tareas.
  2. Registrar las tareas en el sistema operativo.
    1. Arrancar al sistema operativo.
  3. Configurar al sistema operativo.

Tareas

Una tarea en FreeRTOS es una función de C que no devuelve nada, tiene un sólo argumento, y muy importante, nunca debe terminar. Puedes pensar en ellas como las aplicaciones que están corriendo en tu computadora, aunque a una escala más pequeña. No existe ninguna diferencia en la estructura de las tareas ya sea que se creen de forma estática o dinámica; las diferencias las veremos en un momento.

A pesar de que una tarea es una función de C, existen dos características que las hacen diferentes del resto: como recién mencioné, nunca terminan, y sólo deben ser llamadas por el sistema operativo. Veamos una típica tarea:

void led_task( void* pvParameters )
{
   pinMode( 13, OUTPUT );
   // inicializamos al hardware que requiera la tarea

   const portTickType period = pdMS_TO_TICKS( 500 );
   // pdMS_TO_TICKS convierte milisegundos a ticks.
   // Internamente el sistema operativo trabaja con ticks.

   bool state = false;

   while( 1 )
   {
      digitalWrite( 13, state );
      state = !state;

      vTaskDelay( period );
      // manda "dormir" a la tarea por 'period' milisegundos
   }
}

El único parámetro que acepta la función, pvParameters, es del tipo void*. A través de este parámetro podremos pasarle datos a la tarea cuando la estemos registrando. Por lo pronto no lo utilizaremos, pero más adelante lo explicaré.

Registrando las tareas de manera estática

Después de escribir la o las tareas, lo que sigue es registrarlas ante el FreeRTOS. A esta operación se le llama con mucha frecuencia crear la tarea, aunque la realidad es que se está registrando (sin embargo, a partir de este momento le estaré diciendo “crear” porque no quiero parecer un tipo raro). Y aquí se pone muy interesante tanto por la forma en que se crearán las tareas, como por los parámetros para su creación.

FreeRTOS permite crear tareas de dos formas diferentes: de manera dinámica o estática. Que sea una u otra depende de la forma en que se asigne la memoria para dos estructuras de datos fundamentales para cada tarea, la pila y el bloque de control (stack y Task Control Block (TCB), respectivamente).

La pila se utiliza para guardar las variables locales de la tarea, los parámetros de llamadas a funciones, la dirección de regreso de una función llamada, y en su caso, el valor devuelto por la función. Cada tarea necesita de una pila.

El bloque de control, TCB, guarda información importante de cada tarea. Por ejemplo, cuando una tarea se va a dormir debe guardar su estado. Esto es como tomar una foto (o una instantánea) de la tarea en ese preciso momento para cuando regrese más adelante. Recuerda que la tarea libera la CPU al irse a dormir para que otras tareas la usen, pero llegará el momento en que necesite ejecutarse otra vez desde el punto exacto en el que se quedó, como si nunca hubiera pasado nada. Esta información se extrae del TCB cuando la tarea está lista para ejecutarse.

En esta entrada nos concentraremos en la creación estática de tareas, lo cual implica que tú como programador crearás la memoria para la pila y el TCB; pero no te preocupes, es tan fácil como declarar un arreglo y una variable, e involucra más pasos que su contraparte dinámica.

Veamos la firma de la función xTaskCreateStatic() la cual crea tareas de manera estática, y a continuación explicaré cada uno de sus argumentos:

Función para crear tareas estáticas.

pxTaskCode. Es el nombre de la función que implementa la tarea. En nuestro ejemplo, led_task (así, sin paréntesis). Es costumbre agregarle al nombre de la tarea la partícula _task sólo para diferenciarla con facilidad de las demás funciones.

pcName. Es una cadena de texto de C que representa un nombre que le quisiéramos dar a la tarea. Es opcional y lo puedes utilizar cuando estés depurando tus aplicaciones.

ulStackDepth. Es el número de elementos del arreglo puxStackBuffer (el cual veremos a continuación y que hace las veces de pila).

¿Cuál es el tamaño ideal de la pila? Depende. Depende del trabajo que tenga que hacer la tarea (variables locales y profundidad de llamadas a funciones). Escoger el valor idóneo es resultado de intentar y equivocarse (trial and error). Con un valor pequeño la pila se desbordaría, y con un valor muy grande estarías desperdiciando valiosa memoria RAM. FreeRTOS incluye algunas funciones y macros que pueden ayudarte a seleccionar el valor más adecuado. Este es tema de otra entrada. Lo que sí te puedo comentar en este momento es que FreeRTOS incluye una constante con el valor mínimo para el tamaño de pila, configMINIMAL_STACK_SIZE, para el procesador ATmega328.

pvParameters. Si necesitas pasarle argumentos a la tarea aquí es donde lo haces. Si te fijas el tipo de dato de este parámetro es void*, lo que significa que puedes pasarle casi cualquier tipo válido en C, desde un simple entero hasta un tipo compuesto. En esta entrada no lo usaremos, pero en un siguiente post te explicaré cómo usarlo, tanto para pasarle información a la tarea, como para reutilizar el código de una misma tarea.

Cuando no lo utilices deberás escribir el valor NULL.

uxPriority. En un sistema operativo de tiempo real hay tareas más importantes que otras, y las tareas más importantes deben ejecutarse antes, o interrumpir, a las menos importantes. En este parámetro le indicas a FreeRTOS el nivel de importancia de la tarea. El nivel más bajo, o menos importante, es el cero, también indicado por la constante tskIDLE_PRIORITY, y el nivel más alto, o más importante, está dado por configMAX_PRIORITIES – 1. Tú decides cuántos niveles de prioridad vas a tener en tu aplicación dándole un valor a esta constante, la cual se encuentra en el archivo de configuración FreeRTOSConfig.h, del cual hablaré más adelante.

Vamos a suponer que configMAX_PRIORITIES tiene el valor 5; esto significa que tu prioridad más alta será de 4, ya que empezamos en cero. Y las tareas con prioridad 4 (o configMAX_PRIORITIES – 1) serán más importantes que las que tengan prioridad 3, o 2, o 1, o 0.

Y sí, sí es posible tener varias tareas con el mismo nivel de prioridad. Digamos que tienes 3 tareas con la misma prioridad, lo que significa que las 3 están listas para ejecutarse. En este caso FreeRTOS aplica el método Round robin (una tarea depués de la otra). Para que este esquema funcione es necesario que cada tarea libere a la CPU a través de alguno de los mecanismos de FreeRTOS para ello (dormirse, esperar por un evento, o por una liberación voluntaria a partir de una llamada a la función taskYIELD()).

puxStackBuffer. Es un arreglo de tipo StackType_t el cual hará las veces de pila de la tarea. Este arreglo debe ser persistente y deberá ser de tamaño ulStackDepth.

Que sea persistente significa que debe ser declarado de manera global, o dentro de la función main(), o dentro de cualquier función (que no sea tarea y tras ser marcado como static). Lo que no deberías hacer es declararlo dentro de un tarea, ya que estarías utilizando pila de la tarea.

Ten en cuenta que cada elemento de este arreglo es del tipo StackType_t, el cual debe coincidir con el número de bytes utilizado por la pila del procesador elegido. Por ejemplo, en procesadores de 32 bits, como todos los de la familia ARM-Cortex, el tamaño de cada elemento en la pila es de 4 bytes, por lo cual, si estableciste un valor de 128 para ulStackDepth, en realidad estás utilizando 512 bytes.

Pero hay buenas noticias. Para el caso que nos ocupa, el Arduino Uno que utiliza al procesador de 8 bits ATmega328, la conversión es uno a uno, ya que la pila de este chip es también de 8 bits. En términos prácticos, si tú escribes el valor 128 en ulStackDepth , realmente estás utilizando 128 bytes.

Veamos como ejemplo el arreglo para una pila de 128 elementos (que no bytes) declarado con ámbito global:

const uint16_t TASK_LED_STACK_SIZE = 128;
// este valor se utilizará también para el parámetro ulStackDepth

StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

El nombre del arreglo y el de la constante son los que tú quieras, pero como convención les agregamos el nombre de la tarea a la que pertenecen. Y si quisieras crear el arreglo dentro de la función main() te quedaría como (el uso del modificador static no es necesario con esta función, sin embargo, la he marcado así por si quisieras hacer lo mismo en una función diferente):

const uint16_t TASK_LED_STACK_SIZE = 128;
// este valor se utilizará también para el parámetro ulStackDepth

int main()
{
   // ...

   static StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

   // ...
}

pxTaskBuffer. Es un apuntador a una variable estructura de tipo StaticTask_t, la cual hace la función de TCB (Task Control Block, o Bloque de Control) y deberá ser persistente.

Al igual que el arreglo puxStackBuffer, esta variable deberá ser declarada en un ámbito donde no deje de existir, es decir, siguiendo los mismos lineamientos dados más arriba para dicho arreglo.

Valor devuelto. Esta función devuelve una referencia a la tarea recién creada. Esta referencia puede ser utilizada por otras tareas para operar sobre ella. Por ejemplo, una tarea puede decidir que otra ya no es necesaria y la quiere borrar; para ello necesitaría dicha referencia. También las funciones de comunicaciones y sincronización de FreeRTOS necesitan de esta referencia.

Puedes omitir (no guardar) la referencia devuelta si no piensas utilizarla. Pero en caso de que sí la guardes, deberás utilizar una variable global, ya que dicha referencia debe estar disponible para que otras tareas/funciones tengan acceso a ella.

Las tares creadas de manera estática siempre se crean.

Vamos a aplicar todo lo explicado hasta este momento, aunque aún nos faltan varios pasos antes de poder compilar. Registremos la tarea asociada a nuestro ejemplo, led_task():

   xTaskCreateStatic(
      led_task,            // es la función que implementa a la tarea
      "LED",               // es el nombre que le quisimos dar a la tarea
      TASK_LED_STACK_SIZE, // es el número de elementos en la pila
      NULL,                // no vamos a pasarle arguementos a la tarea
      tskIDLE_PRIORITY,    // tskIDLE_PRIORITY es la prioridad más baja: 0
      taskLedRAM,          // es la pila
      &taskLedTCB );       // es la dirección del TCB

      // las tareas estáticas siempre se crean; obtendremos un error en tiempo de compilación
      // en caso de que se termine la memoria.

¿Ya podemos compilar? No, todavía no. Nos faltan dos pasos: arrancar al sistema operativo y configurarlo.

Iniciando al sistema operativo

A diferencia de las aplicaciones en nuestras computadoras de escritorio, donde el sistema operativo arranca apenas presionas el botón de encendido, en el caso de los sistemas embebidos somos nosotros quien lo debemos inicias, pero es muy simple, solamente debes llamar a la función vTaskStartScheduler().

Aún no podemos compilar, nos falta un paso muy importante: la configuración de FreeRTOS.

Configurando a FreeRTOS

FreeRTOS es un sistema operativo muy configurable, y toda la configuración y habilitación de funcionalidades se llevan a cabo en un archivo llamado FreeRTOSConfig.h. Éste lo puedes encontrar en el directorio /tu/ruta/de/instalación/arduino-1.8.13/libraries/FreeRTOS/src. Aunque el archivo incluído en el proyecto Molcajete muestra algunas opciones, en realidad hay muchas más, pero por el momento vamos a enfocarnos en las más importantes y las que tienen que ver con nuestro ejemplo.

configSUPPORT_STATIC_ALLOCATION. Esta constante debe estar puesta a 1 para que puedas utilizar tareas estáticas (y en general, cualquier otro objeto creado de forma estática).

#define configSUPPORT_STATIC_ALLOCATION 1

configMAX_PRIORITIES. Aquí estableces el número máximo de prioridades para tu aplicación. No obstante que puedes usar el número de niveles que tú quieras, debes saber que cada nivel ocupa memoria extra, por lo que este número deberías mantenerlo lo más bajo posible. También recuerda que las tareas pueden compartir el mismo nivel de prioridad.

#define configMAX_PRIORITIES 3

configMAX_TASK_NAME_LEN. ¿Recuerdas que en la función xTaskCreateStatic() hay un parámetro para asociar un nombre (cadena de carácteres) a la tarea? Bueno, aquí estableces el número máximo de carácteres para el nombre de las tareas, incluyendo al carácter de final de cadena. Puedes escribir el número que quieras, pero ten en cuenta que cada tarea creada estará reservando en memoria RAM esa cantidad de bytes. Por ejemplo, si planeas que los nombres tengan una longitud de 4 carácteres, deberas escribir 5 en esta constante. Si quisieras 8 carácteres, entonces escribirías 9, y así sucesivamente.

#define configMAX_TASK_NAME_LEN ( 5 )

INCLUDE_vTaskDelay. Escribiendo un 1 en esta constante estarás habilitando el uso de la función vTaskDelay() en tus programas. Para ahorrar memoria de programa (memoria Flash) FreeRTOS permite habilitar o deshabilitar diferentes funcionalidades. Acabas de ver la habilitación de una de ellas; y si, por ejemplo, no pretendes utilizar a la función vTaskDelayUntil(), entonces escribirías un 0 en la constante INCLUDE_vTaskDelayUntil:

#define INCLUDE_vTaskDelay      1
#define INCLUDE_vTaskDelayUntil 0

TIP: Puedes visitar este enlace para ver todas las opciones configurables posibles en FreeRTOS y su explicación.

TIP: El compilador de C sabe que si no usas una función en tu programa, entonces no debe generarle código. Esto significa que si a la constante INCLUDE_vTaskDelayUntil la pones a 1, pero nunca la llamas, entonces el compilador no le generará código, y ahorrarás un poco de memoria de programa.

IDLE task

Tienes que saber que FreeRTOS necesita (y de manera interna y privada la implementa) una tarea con prioridad 0 (la más baja) para que se ejecute cuando ninguna otra tarea lo esté haciendo, y aunque no tiene un nombre específico, ni nosotros tenemos acceso directo a ella, vamos a llamarle la IDLE task (tarea en espera). Como cualquier otra la tarea la IDLE task requiere memoria para su pila y su TCB. Cuando el soporte para creación de tareas dinámicas está activado (configSUPPORT_DYNAMIC_ALLOCATION vale 1) entonces FreeRTOS de manera automática se las crea, y tú, afortunadamente, te olvidas de ello; pero en el caso que nos ocupa, tareas estáticas, si el soporte para creación dinámico está desactivado (configSUPPORT_DYNAMIC_ALLOCATION vale 0), entonces deberás proporcionarle memoria como a cualquier otra tarea a través de ĺa función vApplicationGetIdleTaskMemory(). No te preocupes, es tan fácil como cortar y pegar el siguiente código, y más aún, ya lo hice por ti en el framework KleOS. En otras palabras, ¡no tienes que hacer nada!, pero tenías que saberlo. En una entrada posterior hablaré más sobre la IDLE task y cómo utilizarla.

#ifdef __cplusplus
extern "C"
{
#endif
    void vApplicationGetIdleTaskMemory( 
        StaticTask_t **ppxIdleTaskTCBBuffer,
        StackType_t **ppxIdleTaskStackBuffer,
        uint32_t *pulIdleTaskStackSize )
    {
        static StaticTask_t xIdleTaskTCB;
        static StackType_t uxIdleTaskStack[ configMINIMAL_STACK_SIZE ];

        *ppxIdleTaskTCBBuffer = &xIdleTaskTCB;
        *ppxIdleTaskStackBuffer = uxIdleTaskStack;
        *pulIdleTaskStackSize = configMINIMAL_STACK_SIZE;
    }
#ifdef __cplusplus
}
#endif

Y como para este ejemplo no es necesario configurar ni habilitar más cosas llegamos al momento crucial de compilar el programa. Antes de que compiles revisemos un programa completo con la tarea de ejemplo led_task():

#include <FreeRTOS.h>
#include <task.h>

const uint16_t TASK_LED_STACK_SIZE = 128;
StaticTask_t taskLedTCB;
StackType_t taskLedRAM[ TASK_LED_STACK_SIZE ];

void led_task( void* pvParameters )
{
   pinMode( 13, OUTPUT );
   // inicializamos al hardware que requiera la tarea

   const portTickType period = pdMS_TO_TICKS( 500 );
   // pdMS_TO_TICKS convierte milisegundos a ticks.
   // Internamente el sistema operativo trabaja con ticks.

   bool state = false;

   while( 1 )
   {
      digitalWrite( 13, state );
      state = !state;

      vTaskDelay( period );
      // manda "dormir" a la tarea por 'period' milisegundos
   }
}

void setup() 
{
   xTaskCreateStatic(
      led_task,            // es la función que implementa a la tarea
      "LED",               // es el nombre que le quisimos dar a la tarea
      TASK_LED_STACK_SIZE, // es el número de elementos en la pila
      NULL,                // no vamos a pasarle arguementos a la tarea
      tskIDLE_PRIORITY,    // tskIDLE_PRIORITY es la prioridad más baja: 0
      taskLedRAM,          // es la pila
      &taskLedTCB );       // es la dirección del TCB

      // las tareas estáticas siempre se crean; obtendremos un error en tiempo de compilación
      // en caso de que se termine la memoria.

   vTaskStartScheduler();
   // arrancamos a FreeRTOS. Éste toma control del sistema a partir de este
   // punto.


   // ¡si llegamos aquí es porque hubo un problema grave!
}

void loop()
{
   // el cuerpo de loop() queda vacío cuando usamos a FreeRTOS 
}

Captura este programa en la IDE de Arduino. Compílalo y súbelo como siempre.

Presta atención a los siguientes puntos:

  • Todos los programas que usen a FreeRTOS deberán incluir a los archivos de encabezado FreeRTOS.h y task.h.
  • El tamaño de la pila está declarado como una constante TASK_LED_STACK_SIZE. Y además, las variables para la pila y el TCB, taskLedRAM y taskLedTCB, respectivamente, están todas declaradas en el ámbito global.
  • Si tu programa está basado en sketch, entonces deberás crear tus tareas en la función setup() de Arduino. Debes saber que es posible que una tarea cree a otra tarea, aunque ese tema lo dejaremos para después.
  • A diferencia del ejemplo de tarea dinámica de la entrada anterior, aquí no es necesario probar que la tarea se haya creado con éxito.
  • Para este ejemplo basado en sketch, en la misma función setup() inicias al sistema operativo.
  • ¡En la función loop() ya no tienes que escribir nada! A partir de ahora todo se lleva a cabo en el código de las tareas y en el propio sistema operativo.
Programa de ejemplo ejecutándose en la tarjeta minimalista UB-1S328, la cual es compatible con Arduino UNO.

¿Pueden las tareas estáticas y dinámicas convivir en el mismo programa?

Absolutamente. Si en tu programa requieres ambas formas, basta con que las constantes respectivas estén puestas a 1:

#define configSUPPORT_STATIC_ALLOCATION 1
#define configSUPPORT_DYNAMIC_ALLOCATION 1

¿Puedo utilizar únicamente tareas estáticas?

Si decides que sólo requieres tareas estáticas en tu aplicación, entonces puedes poner la definición configSUPPORT_DYNAMIC_ALLOCATION a 0. Sin embargo, toma en cuenta lo siguiente: FreeRTOS proporciona cinco versiones para las funciones malloc() y free(). Éstas se encuentran en archivos llamados heap_x.c, donde x va desde 1 hasta 5. En KleOS he incluído todos los esquemas, siendo el 3 el esquema por default.

Pero lo que te quiero decir es que si desactivas por completo el soporte para tareas (y objetos) dinámicos, entonces también deberás eliminar el archivo heap_x.c, porque de no hacerlo el compilador arrojará un error:

/home/fjrg76/arduino-1.8.13/libraries/FreeRTOS/src/heap_1.c:48:6: error: #error This file must not be used if configSUPPORT_DYNAMIC_ALLOCATION is 0
     #error This file must not be used if configSUPPORT_DYNAMIC_ALLOCATION is 0

Es mala idea borrarlo desde el punto de vista que vas a estar trabajando con diferentes proyectos, por lo que te recomiendo renombrarlo en lugar de eliminarlo. Lo que hago es cambiarle el nombre a heap_1.c.txt. Con este cambio el compilador deja de quejarse, y la siguiente vez que lo necesitas vuelves a ponerle su nombre original.

¿Qué sigue?

En la siguiente entrada voy a mostrarte cómo compilar desde la línea de comandos; mientras tanto puedes modificar el programa de ejemplo o escribir nuevos para que pongas en acción lo visto hoy.

Índice del curso

Espero que esta entrada haya sido de tu interés. Si fue así podrías suscribirte a mi blog, o escribirme a fjrg76 dot hotmail dot com, o compartir esta entrada con alguien que consideres que puede serle de ayuda.


Fco. Javier Rodríguez
Escrito por:

Fco. Javier Rodríguez

Soy Ingeniero Electrónico con 20+ años de experiencia en el diseño y desarrollo de productos electrónicos de consumo y a medida, y 12+ años como profesor. Egresado de la UNAM, también tengo el grado de Maestro en Ingeniería por la misma universidad. Mi perfil completo lo puede encontrar en: https://www.linkedin.com/in/fjrg76-dot-com/

Ver todas las entradas

6 COMENTARIOS