PTC29008: Projeto 1: Integração com subsistema de rede do Linux

De MediaWiki do Campus São José
Revisão de 15h49min de 27 de março de 2020 por Msobral (discussão | contribs)
(dif) ← Edição anterior | Revisão atual (dif) | Versão posterior → (dif)
Ir para navegação Ir para pesquisar

Próxima aula

O protocolo em desenvolvimento se situa na camada de enlace. Sendo assim, ele se situa entre as camadas de rede e física, como ilustrado nesta figura.

PTC-Proto-layer-linux.jpg
As camadas de rede e a localização do protocolo


A integração do protocolo de enlace com o subsistema de rede do Linux pode ser feita de duas formas:

  1. Criação de um device driver no kernel Linux: uma tarefa árdua, e que envolve um bom conhecimento sobre o kernel Linux e desenvolvimento de device drivers. As bibliotecas de programação usuais disponíveis em espaço de usuário não podem ser usadas. A depuração é difícil. O protocolo fica bem integrado ao sistema operacional.
  2. Implementação do protocolo em espaço de usuário: um protótipo em espaço de usuário é mais fácil de criar, pois pode usar as APIs existentes para a linguagem de programação escolhida. A depuração fica facilitada. Porém a integração com o sistema operacional apresenta um certo overhead devido ao fluxo de processamento transitar frequentemente entre espaços de sistema e de usuário.


Para o objetivo do projeto 1, a segunda opção é a mais adequada. A implementação em espaço de usuário deve usar uma interface de rede do tipo tun para fazer a integração com o subsistema de rede. Com isso, existe uma interface de rede associada ao enlace estabelecido pelo protocolo, a qual possui parâmetros de rede IP (endereço, máscara), e rotas podem ser definidas para destinos alcançáveis através dela. Com isso, qualquer aplicação TCP/IP pode se comunicar usando o protocolo desenvolvido.


A figura a seguir mostra um diagrama que esquematiza a integração do protótipo com o Linux, evidenciando a interface de rede do tipo tun.


PTC-Proto-tap.jpg


A criação e configuração de uma interface tun pode ser feita de forma programática, usando chamadas de sistema do Linux. O código-fonte a seguir demonstra como uma interface dessas pode ser criada e configurada.


função para criar uma interface tun
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <stdio.h>
#include <net/if.h>
#include <linux/if_tun.h>
#include <string.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h> 


// tun_alloc: cria uma interface tun
// Parâmetro de entrada: 
//  char * dev: o nome da interface ser criada. Se for NULL, interface será denominada pelo sistema (ex: tun0)
//
// Retorno: valor inteiro
//  - se > 0: o descritor de arquivo da interface tun criada.
//  - se < 0, ocorreu um erro

#define MTU 256
#define MASK "255.255.255.252"

int tun_alloc(char *dev)
  {
      struct ifreq ifr;
      int fd, err;

      if( (fd = open("/dev/net/tun", O_RDWR)) < 0 ) {
         perror("");
         return  -1;
      }

      memset(&ifr, 0, sizeof(ifr));

      /* Flags: IFF_TUN   - TUN device (no Ethernet headers) 
       *        IFF_TAP   - TAP device  
       *
       *        IFF_NO_PI - Do not provide packet information  
       */ 
      //ifr.ifr_flags = IFF_TUN | IFF_NO_PI; 
      ifr.ifr_flags = IFF_TUN; 
      if( *dev )
         strncpy(ifr.ifr_name, dev, IFNAMSIZ);

      err = ioctl(fd, TUNSETIFF, (void *) &ifr);
      if( err < 0 ){
         close(fd);
	 perror("");
         return err;
      }
      return fd;
  }    

  // esta outra função configura a interface tun: define os endereços IP do enlace ponto-a-ponto,
  // a máscara de rede /30 e ativa a interface. Ela faz o mesmo que o comando:
  // ifconfig nome_tun IP_da_interface dstaddr IP_da_outra_ponta_do_enlace
  // 
  // esta função deve ser usada assim, supondo que a interface tun se chame tun0, e os endereços IP
  // do enlace sejam 10.0.0.1 e 10.0.0.2:
  //
  // if (set_ip("tun0", "10.0.0.1", "10.0.0.2") < 0) {
  //   perror("so configurar a interface tun");
  //   return 0;
  // }
  //
  // Maiores detalhes sobre as chamadas de sistemas utilizadas: ver "man netdevice", ou 
  // http://man7.org/linux/man-pages/man7/netdevice.7.html

int set_ip(char *dev, char * ip, char * dst) {
  struct ifreq ifr;
  struct sockaddr_in *addr;
  int ok;
  int sd;

  // cria um socket para configurar a interface
  // esse socket não tem nada de especial ... 
  sd = socket(AF_INET, SOCK_DGRAM, 0);
  if (sd < 0) return sd;

  // zera todos os bytes da struct ifreq
  // essa struct contém os atributos a serem configurados na interface
  bzero(&ifr, sizeof(ifr));

  // usa o ponteiro addr para referenciar o campo de endereço da struct ifreq
  // isso facilita o preenchimento dos atributos desse campo
  addr = (struct sockaddr_in*)&(ifr.ifr_ifru.ifru_addr);

  // preenche o campo endereço com o enderço IP da interface
  addr->sin_addr.s_addr = inet_addr(ip);
  addr->sin_family = AF_INET;
  addr->sin_port = 0;

  // escreve o nome da interface na struct ifreq. Isso é necessário
  // para o kernel saber que interface é alvo da operação
  strncpy(ifr.ifr_name, dev, IFNAMSIZ);

  // executa uma operação de configuração de endereço IP de interface
  ok = ioctl(sd, SIOCSIFADDR, &ifr);
  if (ok < 0) return ok;

  // preenche o campo endereço com o endereço IP da outra ponta do enlace
  addr->sin_addr.s_addr = inet_addr(dst);

  // executa uma operação de configuração de endereço IP de destino da interface  
  ok = ioctl(sd, SIOCSIFDSTADDR, &ifr);
  if (ok < 0) return ok;

  // preenche o campo endereço com a máscara de rede da interface
  addr->sin_addr.s_addr = inet_addr(MASK);


  // executa uma operação de configuração de máscara de rede da interface  
  ok = ioctl(sd, SIOCSIFNETMASK, &ifr);
  if (ok < 0) return ok;

  // executa uma operação de configuração de MTU da interface  
  ifr.ifr_mtu = MTU;
  ok = ioctl(sd, SIOCSIFMTU, &ifr);
  if (ok < 0) return ok;

  // lê as flags da interface
  ok = ioctl(sd, SIOCGIFFLAGS, &ifr);
  if (ok < 0) return ok;

  // acrescenta flags UP (ativa) e RUNNING (pronta para uso)
  ifr.ifr_flags |= IFF_UP | IFF_RUNNING;
  
  // executa uma operação de configuração flags da interface  
  return ioctl(sd, SIOCSIFFLAGS, &ifr);
}


Após criar a interface, pode-se testar a comunicação com um simples ping:

ping 10.0.0.2

Para o protocolo PTP, pode ser mais conveniente pensar na interface tun como um objeto. Sua implementação como uma classe pode ser feita como mostrado a seguir:


Um modelo assíncrono para o protocolo


A primeira versão do protocolo em edições anteriores desta disciplina seguiu um modelo síncrono, em que a interface de acesso ao protocolo é composta por chamadas bloqueantes. Isso simplificou o projeto, porém tornou-o limitado. Por exemplo:

  • o protocolo somente recebia um quadro se a aplicação que o utiliza chamar a operação recebe.
  • o gerenciamento de sessão (será visto mais pra frente) não era capaz de realizar a manutenção do enlace
  • comunicações bidirecionais não funcionavam bem

A segunda versão do protocolo buscou sanar essas limitações, redesenhando-o segundo um modelo assíncrono. Nesse modelo, que segue um paradigma orientado-a-eventos (event-driven), o protocolo fica no controle das ações, detectando eventos para tratá-los. Cada evento detectado é encaminhado para o bloco funcional a que se destina. Nenhuma ação do protocolo é bloqueante, pois eventos devem ser tratados o mais rápido possível. Um protocolo que funcione dessa forma deve portanto possuir algum mecanismo de detecção e encaminhamento de eventos.

Ptc-Event-loop.jpg
Um loop de eventos (ou dispatcher, poller): eventos são detectados e encaminhados para tratamento para callbacks previamente definidos


Na versão de referência do protocolo feita em C++, a detecção e encaminhamento de eventos é realizada por um componente denominado Poller. O Poller monitora um conjunto de descritores de arquivos para fins de leitura. Cada descritor é associado a algum bloco funcional do protocolo por meio de um Callback, o qual inclui também um tempo máximo de espera por dados nesse descritor. Quando um descritor tem dados para serem lidos, o Poller executa o Callback correspondente, que tem a responsabilidade de realizar a leitura do descritor e processar os valores lidos. Se um timeout ocorrer, o Poller excuta o Callback correspondente, para que possa tratar o timeout. Por fim, se um descritor for um número negativo, o Poller o considera um timer (uma vez que somente seu timeout deve ser levado em conta). Esse Poller está declarado como mostrado a seguir:

// Poller: um despachador de eventos
// Um objeto poller é capaz de monitorar um conjunto de descritores de arquivos
// e executar um callback para cada desccritor pronto para acesso
// Cada descritor pode especificar um timeout próprio
class Poller {
 public:
  Poller();
  ~Poller();

  // adiciona um evento a ser vigiado, representado por um Callback
  void adiciona(Callback * cb);
  
  // remove callback associado ao descritor de arquivo fd
  void remove(int fd);
  void remove(Callback * cb);
  
  // remove todos callbacks
  void limpa();
  
  // vigia os descritores cadastrados e despacha os eventos (chama os callbacks)
  // para ser lido, ou até que expire o timeout (em milissegundos)
  void despache_simples();
  void despache();
  
 protected:
     list<Callback*> cbs_to;
     map<int,Callback*> cbs;
};


Um Callback, por sua vez, é definido pela classe abstrata Callback. Cada tipo de evento deve ter seu Callback específico definido como uma especialização dessa classe.

// classe abstrata para os callbacks do poller
class Callback {
public:
    // fd: descritor de arquivo a ser monitorado. Se < 0, este callback é um timer
    // tout: timeout em milissegundos. Se < 0, este callback não tem timeout
    Callback(int fd, long tout);
    
    // cria um callback para um timer (equivalente ao construtor anterior com fd=-1)
    // out: timeout
    Callback(long tout);
    
    // ao especializar esta classe, devem-se implementar estes dois métodos !
    // handle: trata o evento representado neste callback
    // handle_timeout: trata o timeout associado a este callback
    virtual void handle() = 0;
    virtual void handle_timeout() = 0;

    // operator==: compara dois objetos callback
    // necessário para poder diferenciar callbacks ...
    virtual bool operator==(const Callback & o) const;
    
    // getter para o descritor de arquivo a ser monitorado
    int filedesc() const;

    // getter do valor de timeout remanescente
    int timeout() const;

    // ajusta timeout restante
    void update(long dt); 

    // recarrega valor de timeout inicial
    void reload_timeout();

    // desativa timeout
    void disable_timeout();

    // reativa timeout
    void enable_timeout();

protected:
    int fd; // se < 0, este callback se torna um simples timer
    long tout; 
    long base_tout;// milissegundos. Se <= 0, este callback não tem timeout
};


Um exemplo para uso do Poller é este:

#include <iostream>
#include <string>
#include "poller.h"

using namespace std;

class CallbackStdin: public Callback {
 public:
  CallbackStdin(long tout): Callback(0, tout) {}

  void handle() {
    string w;

    getline(cin, w);
    cout << "Lido: " << w << endl;
  }

  void handle_timeout() {
     cout << "Timeout !!!" << endl;
  }

};

int main() {
  CallbackStdin cb(5000);
  Poller sched;

  sched.adiciona(&cb);
  sched.despache();
}

Algo parecido em Python

Existe uma API Python chamada asyncio (veja também este tutorial), que é capaz de escalonar eventos e tarefas. Essa API pode ser usada para escrever programas orientados a eventos, porém é um pouco mais complexa do que o poller aqui apresentado. Sendo assim, foi escrita uma versão do poller para Python (mas asyncio tem muito mais recursos !).

A biblioteca padrão Python possui a classe DefaultSelector, que se assemelha ao Poller. Porém DefaultSelector não possibilita que se associe um diferente timeout para cada callback. Esse tipo de controle deve ser realizado pelo código que usa DefaultSelector.

Este exemplo mostra o uso de DefaultSelector:

#!/usr/bin/python3

import selectors
import sys

Timeout = 5 # 5 segundos

# um callback para ler do fileobj
def handle(fileobj):
  s = fileobj.readline()
  print('Lido:', s)

sched = selectors.DefaultSelector()
sched.register(sys.stdin, selectors.EVENT_READ, handle)

while True:
  eventos = sched.select(Timeout)
  if not eventos: # timeout !
    print('Timeout !')
  else:
    for key,mask in eventos:
      cb = key.data # este é o callback !
      cb(key.fileobj)


Devido a essa limitação em DefaultSelector, a classe Poller foi implementada também em Python.

O Poller reimplementado em Python

As classes Poller e Callback foram implementadas em Python, porém explorando algumas facilidades existentes nessa linguagem. Ambas estão disponíveis no módulo poller:


Um programa de demonstração parecido com aquele em C++ foi criado para mostrar o uso da classe Poller:

#!/usr/bin/env python3

import poller
import sys,time

class CallbackStdin(poller.Callback):
    
    def handle(self):
        l = sys.stdin.readline()
        print('Lido:', l)
        
    def handle_timeout(self):
        print('Timeout !')
   
class CallbackTimer(poller.Callback):

    t0 = time.time()
    
    def __init__(self, tout):
        poller.Callback.__init__(self, None, tout)
        
    def handle_timeout(self):
        print('Timer: t=', time.time()-CallbackTimer.t0)
        
cb = CallbackStdin(sys.stdin, 3)
timer = CallbackTimer(2)
sched = poller.Poller()

sched.adiciona(cb)
sched.adiciona(timer)

sched.despache()

Atividade

O protocolo até o momento se resume ao enquadramento. Integre-o com o kernel Linux, de forma que o enlace estabelecido seja apresentado como uma interface de rede ponto-a-ponto ... você precisará usar uma interface do tipo tun e remodelá-lo para que os eventos sejam tratados assincronamente.

Classe Enquadramento como uma especialização de Callback (exemplo C++)
#ifndef FRAMING_H
#define FRAMING_H
 
#include <cstdint>
#include "Serial.h"
#include "Callback.h"
 
class Enquadramento : public Callback {
 public:
  Enquadramento(Serial & dev, int bytes_min, int bytes_max);
  ~Enquadramento();
 
  // envia o quadro apontado por buffer
  // o tamanho do quadro é dado por bytes 
  void envia(char * buffer, int bytes);
 
  // espera e recebe um quadro, armazenando-o em buffer
  // retorna o tamanho do quadro recebido
  int recebe(char * buffer);

  // os tratadores de eventos chamados pelo poller 
  void handle();
  void handle_timeout();

 private:
  int min_bytes, max_bytes; // tamanhos mínimo e máximo de quadro
  Serial & porta;  
  char buffer[4096]; // quadros no maximo de 4 kB (hardcoded)
 
  enum Estados {Ocioso, RX, ESC};
 
  // tipos de eventos associados à serial
  enum TipoEvento {Dado, Timeout};

  // tipo Evento: representa um evento
  struct Evento {
    TipoEvento tipo;
    uint8_t octeto;

    // este construtor cria um Evento do tipo Timeout
    Evento(): tipo(Timeout) {}

    // este construtor cria um Evento do tipo Dado
    Evento(uint8_t x): tipo(Dado), octeto(x) {}
  };

  // bytes recebidos pela MEF até o momento  
  int n_bytes; 
 
  // estado atual da MEF
  int estado;
 
  // aqui se implementa a máquina de estados de recepção
  // retorna true se reconheceu um quadro completo
  // A máquina de estados recebe como parâmetro um Evento
  bool handle_fsm(Evento & ev);
 
};
 
#endif

Plataforma de desenvolvimento e teste com Virtualbox

O teste de comunicação através de um enlace entre duas interfaces tun demanda que essas interfaces residam em hosts diferentes. Se ambas interfaces estiverem no mesmo host, a comunicação se dará na realidade através da interface loopback. Uma plataforma de teste simplificada pode ser implantada usando o Virtualbox:

  1. Na máquina real execute o serialemu: uma das pontas da serial emulada deve ser usada pelo protocolo na máquina real.
  2. Porta serial na máquina virtual: antes de executar a máquina virtual, habilite sua primeira porta serial e associe-a à outra serial do serialemu (ver figura a seguir).
  3. Porta serial dentro da máquina virtual: uma vez iniciada a máquina virtual, deve-se nela executar o protocolo de forma que use a porta serial /dev/ttyS0.


PTC-Serial-virtualbox.png
Configuração da porta serial na máquina virtual: clique na máquina virtual com o botão da direita, e selecione o menu Configurações (ou Settings)