En el principio, era la nada.
Después, cuando empezó a existir cierto número de computadoras, surgió la necesidad de hacer que compartieran información entre ellas. Para hacerlo surgió un modelo muy interesante conocido como "Modelo OSI" que dio una base sólida para cosas ahora tan enormes como la Internet misma.
En este documento vamos a explorar la creación de un protocolo de comunicación sobre la séptima capa del modelo OSI, es decir, sobre la capa de aplicación; además, para agregar un poco de complejidad y hacerlo interesante, vamos a hacer que sea un protocolo "binario", quiere decir que la información se transmitirá sin forzar su conversión a trenes de datos legibles al humano.
Antes de empezar a definir el "protocolo" sería interesante hacer una breve definición de qué es un protocolo y por qué es necesario definir uno personalizado; primero, podemos entender como "protocolo" una serie de reglas y acuerdos que dos (o más) entidades deben de cumplir para tener una comunicación exitosa. Así como entre humanos el protocolo implica que una persona salude con ciertas palabras antes de iniciar interacción con otra, y se establecen ciertas reglas para intercambiar la información (solo una persona puede hablar a la vez, por ejemplo), y que si se rompen esas reglas deja de existir la posibilidad de comunicación, así las computadoras necesitan tener reglas claras y explícitas de cómo comunicarse entre sí. Por otro lado, cuando definimos una aplicación en cuyo proceso se incluye la comunicación con otras instancias, necesitaremos establecer el protocolo específico con el cual va a suceder la comunicación. Además dicho protocolo estará encima de capas inferiores como TCP.
Una vez aclarado, vamos a ello.
Primero: ¿qué vamos a decir?
Antes de empezar a pensar en las reglas que vamos a seguir para comunicarse, es necesario poder tener una idea clara de qué información es relevante para ser transmitida: ¿necesitamos transmitir cadenas de texto? ¿números enteros? ¿una serie de banderas?...
En este caso, vamos a pensar en transmitir mensajes de un teórico dispositivo electrónico que tiene:
- Un identificador de longitud fija.
- Un sensor analógico de alta precisión
- 4 sensores digitales
- Un sensor analógico de baja precisión.
- Una cadena de estado de longitud variable.
También podemos esperar que recibimos paquetes de diferentes tipos: recibimos información periódicamente, también recibimos paquetes emergentes cuando ciertos sensores son activados, y paquetes de fin de alerta.
Segundo: ¿Cómo lo organizamos?
Ahora ya sabemos qué va a contener nuestra carga útil (payload) y es momento de definir el espacio necesario, en bytes o bits, para transmitir esos valores, a saber:
- El identificador puede ser contenido en 4 bytes, nos da la oportunidad de tener hasta 2³² identificadores posibles. Sin embargo, nosotros vamos a leer el identificador como la representación hexadecimal del mismo, lo que nos da un rango desde 0x00 0x00 0x00 0x00 hasta 0xFF 0xFF 0xFF 0xFF.
- El sensor analógico de alta precisión envía valores desde 0 hasta 49,999, así que podemos usar cómodamente dos bytes que nos dan un rango de 65,536 valores posibles.
- Con solo 4 bits podemos transportar los cuatro sensores digitales.
- El sensor de baja precisión envía valores entre 0 y 9, por tanto, podemos usar tan solo 4 bits, que nos dan 16 valores posibles.
- La cadena de longitud variable se vuelve complicada. Podemos establecer una longitud máxima de 128 bytes.
Sumando todos los valores adicionales tenemos 4 + 2 + 1 + 128 = 135 bytes como el tamaño máximo de nuestra carga útil, que necesitaremos. A partir de ahora, tenemos que establecer el envoltorio para transportarlo.
Tercero: ¿Cómo lo empaquetamos?
Una vez que sabemos qué vamos a transportar y cuánto espacio va a ser necesario, entonces tenemos que establecer el envoltorio necesario para que pueda ser interpretado exitosamente.
Suele ser muy útil tener un marcador de inicio de paquete: por un lado nos ayuda a identificar el inicio de un paquete en caso de tener mucha más información en el buffer; un marcador de inicio también nos sirve para determinar si el paquete es válido o no; otro dato muy útil es saber el tamaño total del paquete que estamos esperando, momento en el tiempo en que se originó el paquete y por último, el tipo del paquete que estamos recibiendo.
Para ponerlo en orden, podemos expresarlo de la siguiente manera:
- 2 bytes para poner el marcador de inicio de paquete.
- 1 byte para el tipo. Aquí hay que mencionar que tenemos solo tres tipos de paquete, que pueden ser expresados en 2 bits, sin embargo, estamos reservando (arbitrariamente) el resto para uso futuro.
- 1 byte para el tamaño del paquete, que nos establece un tamaño máximo de paquete de 255 bytes. El tamaño será calculado a partir de este punto, excluyendo este byte.
- Las fechas siempre son complicadas: podemos usar 6 bytes, y expresar en cada uno de ellos año, mes, día, horas, minutos, segundos respectivamente. Otra opción puede ser transmitir el epoch en 4 bytes, cuyo valor máximo es 4,294'967,296. Por agregarle algo de sabor al asunto, vamos a usar la segunda opción.
A partir de aquí empezará nuestra payload que es donde está el contenido útil del paquete. En este punto hemos tomado las decisiones suficientes para poder pensar en un paquete real que nos sirva de ejemplo.
Cuarto: El paquete de ejemplo.
Vamos a asumir la siguiente información para nuestro paquete:
- Es un paquete de tipo informativo. En este punto vamos a asignar arbitrariamente valores a los tipos de paquete:
- 0x01: informativo.
- 0x02: Inicia una alerta.
- 0x03: Termina una alerta.
- Fue originado en 2022-03-26 00:57:37 (UTC), Tiempo Unix: 1648256257.
- El identificador del dispositivo es 0x0F 0x0A 0x12 0xE8
- El sensor de alta precisión envía un valor de 23,454
- Los sensores digitales están en el siguiente estado: 1, 1, 0,1
- El sensor de baja precisión envía un valor de 6
- La cadena variable será "Prueba de paquete enviado".
Es fácil notar que hay algunos datos faltantes:
- El iniciador de inicio del paquete, que lo vamos a establecer como una constante: 0xFF 0xFF.
- El tamaño del paquete, que será calculado de la siguiente manera:
- 4 bytes de fecha +
- 4 bytes de identificador +
- 4 bytes del sensor de alta precisión +
- 1 byte para el sensor de baja precisión y los sensores digitales +
- 26 bytes para la cadena variable.
- Eso nos arroja un total de 39 bytes.
Notarán que ese cálculo de tamaño no está considerando los 4 bytes iniciales. Considerando esos bytes iniciales, nuestra transmisión total será de 43 bytes; ahora, transformados en sus expresiones hexadecimales, y considerando el bit más significativo de izquierda a derecha, tendríamos los siguientes valores (expresados en hexadecimal):
- Inicio del paquete: 0xFF 0xFF.
- Tipo de paquete: 0x01.
- Tamaño del paquete: 0x27.
- Momento en que se origina el paquete: 0x623E6501.
- Identificador del dispositivo: 0x0F 0x0A 0x12 0xE8.
- Sensor de alta precisión: 0x5B9E.
- El sensor de baja precisión y los sensores digitales están contenidos en los siguientes bits 0b0110 0110 y en expresado en hexadecimal 0x66.
- Y por último, el ASCII expresado en hexadecimal nos da los siguientes bytes: 50 72 75 65 62 61 20 64 65 20 70 61 71 75 65 74 65 20 65 6E 76 69 61 64 6F.
Todo junto queda expresado, en hexadecimal de la siguiente manera:
FF FF 01 27 62 3E 65 01 0F 0A 12 E8 5B 9E 66 50 72 75 65 62 61 20 64 65 20 70 61 71 75 65 74 65 20 65 6E 76 69 61 64 6F
Aquí es una buena oportunidad para aclarar por qué se usa una expresión hexadecimal: solamente por la facilidad de expresión en dos dígitos del valor máximo de un byte: 255, 0xFF, 0b1111 1111. Muchos programadores nóveles suelen creer que existe alguna especie mística o diferencia entre el valor hexadecimal y el decimal, sin embargo es solamente una convención que está desde siempre y que rara vez se cuestiona.
Quinto: Enviar y recibir el paquete.
Un poco contrario a la costumbre de este blog, aquí vamos a incluir el código fuente en C para armar este paquete con sus debidos comentarios para dejar un ejemplo claro de cómo manejar el envío de información.
int envia() {
// Los valores se pueden recibir como parámetros
// en el ejemplo, están como constantes
uint8_t *paquete;
uint8_t pos = 0;
unsigned tamTotal = 0; //Tamaño total del paquete.
unsigned tamPaquete = 0; //Tamaño de la carga útil.
unsigned i = 0; //Es solo para los ciclos.
int enviados = 0; //Para regresar el valor.
int socket = 0x05; //Variable donde va el FileDescriptor del socket de red
//Valores a enviar, podrían llegar como parámetros.
uint8_t id[4] = {0x0F, 0x0A, 0x12, 0xE8};
char variable[26] = "Prueba de paquete enviado";
uint16_t sensorAlta = 23454;
uint8_t sensorBaja = 6;
uint8_t digitales = 0b00001101;
union momento {
uint32_t epoch;
uint8_t[8] enBytes;
}
//La operación se deja explícita solo por referencia.
tamPaquete = 4 + 4 + 4 + 1 + varTam;
tamTotal = tamPaquete + 4;
paquete = (uint8_t*)malloc(tamTotal);
//Evitamos información residual en el buffer
memset(paquete, 0x00, tamTotal);
//Ponemos el inicio del paquete
paquete[pos++] = 0xFF;
paquete[pos++] = 0xFF;
paquete[pos++] = 0x01;
paquete[pos++] = tamPaquete;
//Obtener la fecha del sistema
momento.epoch = (uint32_t)time(NULL);
//Copiar los bytes de la fecha
for (i = 0; i < 4; i++) {
paquete[pos++] = momento.enBytes[i];
}
//Establecemos el identificador
for (i = 0; i < 4; i++) {
paquete[pos++] = id[i];
}
//Por gesto cultural, vamos a usar otro método para copiar valores
/*
Voy intentar expresar aquí las operaciones a nivel bit que se realizan:
01011011 10011110 >> 8 == 00000000 01011011 (Conservamos la inforamción del byte 0)
Luego, al asignarse a espacio de 8 bits solamente, se hace de derecha a izquierda
por lo que solamente queda 01011011.
*/
paquete[pos++] = sensorAlta >> 8;
/*
En este caso, tenemos que mandar a ceros el byte de la izquierda, para conservar
únicamente los datos del byte de la derecha:
01011011 10011110 & 00000000 11111111 == 00000000 10011110
La operación AND devuelve solamente 1 si ambos bits son 1. Y una vez más conservamos
solamete el dato que nos importa
*/
paquete[pos++] = sensorAlta & 0x00FF;
/*
Después de las operaciones anteriores, el buffer debe quedar así:
paquete[N + 0] == 01011011
paquete[N + 1] == 10011110
*/
//Sensor de baja precisión... desplazamos 4 bits el entero para conservar solamente
//el dato que nos importa, en la posición adecuada.
paquete[pos] = (sensorBaja << 4);
//Utilizando el operador OR sobre el byte podemos hacer que se conserve la inforamción
//previa y encender los bits restantes que necesitamos.
paquete[pos++] |= digitales;
//Por último, la cadena variable.
for (i = 0; i < varTam; i++) {
paquete[pos++] = variable[i];
}
// y enviamos por el socket
enviados = send(socket, paquete, tamPaquete, 0x00);
//Limpiamos...
free(paquete);
//Regresamos los bytes enviados...
return enviados;
}
Para decodificar el paquete recibido podemos aplicar las mismas estrategias en sentido inverso, teniendo cuidado con el conteo de bytes y sabiendo claramente cómo está organizado el paquete.
int recibe() {
//Usamos el valor constante 255 como tamaño máximo de la lectura.
char buff[255];
uint8_t pos = 0;
unsigned tamPaquete = 0; //Tamaño de la carga útil.
unsigned i = 0; //Es solo para los ciclos.
int recibidos = 0; //Para regresar el valor.
//Vamos a usar estas variables para recibir los valores.
uint8_t tipo = 0;
uint8_t id[4];
char *variable;
unsigned tamCadena;
uint16_t sensorAlta = 0;
uint8_t sensorBaja = 0;
uint8_t digitales = 0;
//Sí, volvemos a usar un union para poder recibir este dato
union momento {
uint32_t epoch;
uint8_t[4] enBytes;
}
//Prevenimos la presencia de información inválida en la memoria.
memset(buff, 255, 0x00);
//Leemos el encabezado del paquete, sabemos que en los primeros 4 bytes
//vamos a tener información suficiente para actuar.
recibidos = read(buff, 4);
//La red tenía los suficientes bytes para trabajar?
if (recibidos == 4) {
//El paquete tiene el encabezado que esperamos donde esperamos?
if (buff[0] == 0xFF && buff[1] == 0xFF) {
//Primer dato: el tipo del paquete.
tipo = buff[2];
//Recibimos el tamaño, nos va a ayudar a planear nuestra siguiente lectura.
tamPaquete = buff[3];
//Ya sabemos cuantos bytes esperamos, vamos por el resto.
recibidos = read(buff, tamPaquete);
//Una vez más: validamos que el paquete esté completo en la red.
if (recibidos == tamPaquete) {
//Esta variable irá avanzando para no perder el punto de lectura.
pos = 0;
//Volcamos los bytes de la fecha en la representación de arreglo.
for (i = 0; i < 4; i++) {
momento.enBytes[i] = buff[pos++];
}
//Ahora tiempo de envío está en momento.epoch, la representación como entero.
//Leemos los bytes del ID.
for (i = 0; i < 4; i++) {
id[i] = buff[pos++];
}
/*
Una vez más, esto merece una explicación detallada.
En el buffer de entrada, tenemos lo siguiente:
paquete[N + 0] == 01011011
paquete[N + 1] == 10011110
Y la variable del sensor, está en ceros, entonces, aplicamos OR para copiar
la información desde el buffer, y la desplazamos para que quede en el lugar adecuado:
00000000 00000000 | 01011011 << 8 == 01011011 00000000
luego, simplmemente copiamos el otro byte en la posición 0
01011011 00000000 | 10011110 == 01011011 10011110
... y la magia está hecha! tenemos re ensamblado nuestro entero de 16 bits
*/
sensorAlta = buff[pos] << 8 | buff[pos + 1]; pos += 2;
//Aquí viene lo interesante: sensor de baja
//Una vez más, tomamos los 4 bits iniciales y los desplazamos 4 posiciones.
sensorBaja = buff[pos] >> 4;
//y de manera análoga aplicamos AND para filtrar los cuatro bits restantes
digitales = buff[pos] & 0b00001111;
pos += 1;
//Atención que dejamos un byte nulo al final de la cadena.
tamCadena = (tamPaquete - pos) + 1;
//Reservamos la memoria
variable = (char*)malloc(tamCadena);
//Limpiamos la información residual
memset(variable, 0x00, tamCadena);
for (i = 0; i < tamCadena; i++) {
variable[i] = buff[pos++];
}
//Lectura terminada... limpiamos
} else {
//ERROR: El paquete está incompleto.
}
} else {
//ERROR: No es el encabezado que esperamos
}
} else {
//ERROR: no hay suficiente información en el encabezado
}
free(buff);
}
Conclusiones:
Los protocolos de comunicación son un tema sumamente interesante, sin embargo, se suele hablar poco de ellos. Entender los mecanismos y la lógica que subyace debajo de la definición de los mismos, más allá de la simple utilización, nos puede ser de mucha utilidad a la hora de construir aplicaciones más rápidas, consolidadas y eficientes, reduciendo la necesidad de usar librerías o protocolos de terceros que pueden ser demasiado generales, pesados o simplemente inseguros.