Mochileiro T.I
Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors

Protocolo I2C: entendendo o papel do Endianness

Ler dados de um dispositivo I2C pode ser uma tarefa confusa, principalmente por conta da convenção de Endianness adotada pela arquitetura do processador receptor. Neste artigo, você verá como o protocolo I2C funciona em linhas gerais, o que é Endianness e, com exemplos em C/C++ e Python, como ele pode influenciar os dados recebidos.

Embora antigo, o protocolo I2C ainda é amplamente usado por ser simples, barato, flexível e possuir um vasto ecossistema com bibliotecas prontas para vários dispositivos.

No entanto, é comum nos depararmos com situações onde não existem bibliotecas prontas para trabalhar com o dispositivo que desejamos, como é o caso do tuner de rádio TEF6686.

Nestes casos, precisamos descer um pouco o nível e programar mais próximo ao hardware para interagir com o protocolo I2C e fazer o dispositivo funcionar conforme desejamos.

Porém essa tarefa não é tão trivial quanto parece, visto que além de saber como o dispositivo funciona, temos que entender o meio de comunicação e como o processador gerencia as informações.

Nesse sentido, ainda que superficialmente, é imprescindível conhecer como o protocolo I2C lida com os dispositivos e suas respectivas informações.

Além disso, para interpretar corretamente os dados, é essencial entender como processadores como AVR, ARM e x86 leem e escrevem informações na memória — conceito de Endianness.

Por fim, precisamos compreender como diferentes linguagens de programação como C/C++ e Python acessam a memória do computador ou microcontrolador.

Desta maneira, o objetivo aqui é apresentar sucintamente as principais características do I2C, explicar o que é Endianness e mostrar por que este conceito é fundamental para a correta interpretação dos dados.

Após uma breve introdução teórica, você verá através de exemplos práticos em C/C++ e Python como isso tudo funciona para evitar erros comuns de programação.

Caso queira ver um exemplo real de comunicação entre um Raspberry Pi e um dispositivo I2C, recomendo a leitura do artigo TEF6686 no Raspberry: como interagir com o chip.

Boa leitura!

O que é o protocolo I2C?

O I2C é um protocolo de comunicação serial criado pela Philips (hoje NXP) nos anos 1980. Ele permite que múltiplos dispositivos como sensores, displays, etc se comuniquem com microcontroladores.

A comunicação acontece através de um barramento físico composto por apenas dois fios: SDA (Serial Data) e SCL (Serial Clock).

Todos os dispositivos conectados a este barramento ficam subordinados a um dispositivo Master (normalmente o microcontrolador) que controla toda a comunicação.

Os demais dispositivos tornam-se Slaves e recebem um endereço único dentro do barramento. Em teoria, o I2C pode suportar até 127 endereços diferentes, ou seja, 127 dispositivos ao mesmo tempo.

É importante ressaltar que o projeto do protocolo I2C visa a comunicação entre chips. Em outras palavras, ele visa a comunicação dentro de um mesmo equipamento.

Portanto, o protocolo não serve bem para longas distâncias, variando de alguns centímetros a cerca de 1 metro em condições ideais. (dependendo da capacitância total do barramento).

Outro aspecto muito importante e fundamental para este artigo é saber que o protocolo I2C transmite apenas 1 byte (8 bits) por vez.

Isso significa que se um Slave qualquer precisa enviar uma informação de 4 bytes ao Master, essa informação será dividida em quatro partes de 1 byte cada.

Trata-se de uma característica de design do protocolo e não uma limitação. Veremos em breve como manipular esses dados fracionados de forma correta e eficiente.

O que é o Endianness?

Endianness é uma convenção de representação de dados em memória, adotada pela arquitetura do processador.

Existem vários tipos de representação, no entanto, encontramos em quase todo tipo de hardware apenas duas delas: Little Endian e Big Endian.

Bem resumidamente, essas representações organizam os dados na memória de forma contígua, do byte mais significativo ao menos significativo e vice-versa, a depender do tipo do Endian.

Em termos técnicos, chamamos o byte mais significativo de MSB (Most Significant Byte) e o menos significativo de LSB (Least Significant Byte).

Mas o que significa “mais significativo” e “menos significativo”? Imagine o número 321, por exemplo. Primeiramente escrevemos o número 3 (centena), depois o 2 (dezena) e por último o 1 (unidade).

Isso significa que, nós humanos, organizamos esse tipo de dado do número mais significativo (centena) ao menos significativo (unidade), da esquerda para direita.

Nesse sentido, essa mesma lógica vale para a forma como os processadores armazenam os dados em memória.

Veja como funciona na prática. Vamos pegar o mesmo número do exemplo anterior (321) e convertê-lo para binário, representação que as máquinas entendem.

Temos, portanto, que o decimal 321 é igual ao binário 00000001 01000001. A classificação de mais e menos significativo nos binários sempre é feita analisando-os em “blocos” de oito números cada.

Portanto, a “parte” mais significativa do binário 00000001 01000001 é 00000001 e a menos significativa é 01000001. Cada “parte” ocupa oito bits, totalizando dezesseis bits.

Como a memória é divida em blocos de um byte (8 bits) cada, armazenar o número 321 ocupará dois blocos (2 bytes, 16 bits).

Se adotarmos a “convenção humana” de representação de dados, o processador armazenará no primeiro bloco o binário mais significativo e no segundo bloco, o menos significativo.

No entanto, nem sempre é nessa ordem que os processadores armazenam seus dados!

Big Endian

A convenção Big Endian, adotada por poucas arquiteturas de processadores, se parece com a forma como, nós humanos, escrevemos e lemos números.

Em outras palavras, nessa abordagem o processador grava e lê dados sempre do MSB (mais significante) ao LSB (menos significante), do menor endereço de memória ao maior.

O exemplo anterior, onde vimos o número 321, é exatamente como o Big Endian funciona. Para ilustrar, veja na representação abaixo como ele ficaria armazenado:

Representação big-endian na memória

Imagine que essa ilustração representa apenas um pedaço da memória total de um computador, mais especificamente dos endereços 1000 a 1004.

Note que o foram reservados dois blocos para armazenar o número do nosso exemplo, cujos endereços são 1000 e 1001.

Dentro da faixa reservada de endereços, veja que o byte mais significativo (MSB) ficou no menor endereço (1000) e o byte menos significativo (LSB), no maior endereço (1001).

Quando o processador precisar ler esse número, primeiramente ele pegará o conteúdo do endereço 1000 e depois o conteúdo de 1001 para recompor o binário que representa o decimal 321.

Apesar de ser uma abordagem amigável a nós humanos, a maioria das arquiteturas de processadores não utilizam o Big Endian.

Elas fazem exatamente ao contrário através do Little Endian, conforme veremos a seguir!

Little Endian

Adotada pela maioria das arquiteturas de processadores (x86, x64, ARM), o Little Endian manipula inversamente os dados na memória.

Em outras palavras, o processador grava e lê os dados sempre do LSB (menos significativo) ao MSB (mais significativo), também do menor endereço de memória ao maior.

Grosseiramente falando, é como se o processador manipulasse dos dados de trás para frente. A imagem abaixo, com o nosso já conhecido exemplo, ajudará a entender melhor:

Representação little-endian na memória

Veja que agora o byte menos significativo (01000001) vem primeiro na memória (endereço 1000) e o byte mais significativo (00000001) vem em seguida. (endereço 1001).

É importante destacar que embora o processador manipule inversamente os valores com o Little Endian, ele o faz de maneira transparente a nós programadores.

No entanto, só precisamos nos preocupar quando fazemos acesso direto a algum endereço de memória, conforme veremos adiante.

Imagino que, neste momento, você esteja se perguntando por que os processadores “preferem” essa abordagem “inversa” de leitura e escrita.

É feito dessa maneira porque simplifica muito as operações matemáticas no hardware. Faz mais sentido para o processador começar uma soma, por exemplo, pelo byte menos significativo.

Uma vez que o LSB vem primeiro, isso poupa o processador de percorrer endereços na memória desnecessariamente até encontrá-lo.

Além disso, o Little Endian facilita a manipulação de dados de tamanhos diferentes e reduz a complexidade do design da CPU.

Existem ainda outros motivos, mas como não é esse o foco deste artigo, os deixarei de lado para não desviar muito do assunto!

Afinal, qual é a relação entre o protocolo I2C e o Endianness?

Finalmente chegou a parte onde as coisas ficam um pouco nebulosas e podem induzir ao erro, especialmente com a manipulação direta de memória.

Conforme vimos anteriormente, uma das principais características do protocolo I2C é a transmissão de pacotes de dados com tamanho de um byte cada.

Se o dispositivo não gera dados completos maiores que um byte, não temos que nos preocupar.

Em contrapartida, se ele gera dados maiores que isso, o I2C precisará quebrá-los em pacotes de um byte para transmiti-los sequencialmente.

Vamos mais uma vez pegar o exemplo do número 321. Já sabemos que seu tamanho é de dois bytes.

Logo, se um dispositivo I2C qualquer precisa enviá-lo ao Master, ele precisará quebrar o 321 em duas partes e enviá-las uma por vez.

A maioria dos dispositivos transmite seus dados no estilo Big Endian, ou seja, inicia a transmissão do MSB para o LSB. Vamos utilizar essa convenção para o nosso exemplo:

Protocolo I2C e mensagem de 16 bits

Então o dispositivo Master (Raspberry, Arduino), lê o pacote 1 e o grava na memória. Em seguida, lê o pacote 2 e faz o mesmo.

O problema nisso é que a transmissão segue o padrão Big Endian e o processador manipula os dados recebidos com a convenção Little Endian.

Isso faz com que os dados sejam interpretados de maneira invertida, comprometendo a integridade da informação.

A seguir, vamos ver a incompatibilidade de Endianness na prática e como corrigi-la!

Exemplo protocolo I2C com C/C++

A linguagem C/C++ oferece ao programador grande flexibilidade no acesso direto e gerenciamento manual da memória.

Isso significa que, ao contrário de outras linguagens consideradas de “mais alto nível”, em C/C++ temos que nos preocupar com a forma como gravamos ou lemos informações.

Dito isto, considere que o trecho abaixo simula o envio de dados via protocolo I2C. Nesse sentido, cada vez que a função read() é chamada, ela retorna um valor de oito bits:

/* Simula o envio do número 321 via I2C.
A função read() simula o envio 8 bits por vez, 
conforme o protocolo I2C determina.
*/

// 321 (00000001 01000001) em dois "pacotes" de 1 Byte
uint8_t data[] = { 0b00000001, 0b01000001 };
int i = 0;

// "Recebe" um pacote por vez
uint8_t read(){
  if (i < sizeof(data))
    return data[i++];
  else
    return 0;
}

Para ficar mais didático, imagine que o valor binário de 321 está na memória de um dispositivo I2C Slave qualquer e a função read() (no Master) obtém “pedaços” de 8 bits através do barramento.

Agora que já temos um método capaz de “solicitar” dados, vamos utilizá-lo e ver como o processador os interpreta.

Observe o trecho restante do código:

int main(){
    // Armazena o valor 321
    uint16_t received = 0;
    // Para armazenar 8 bits por vez
    uint8_t *ptrReceived = (uint8_t *)&received;

    //Obtém o primeiro pacote (00000001) e armazena nos primeiros 8 bits de received
    *ptrReceived++ = read();
    //Obtém o segundo pacote (01000001) e armazena nos últimos 8 bits de received
    *ptrReceived = read();

    printf("Valor recebido: %" PRIu16 "\n", received);

    return 0;
}

Veja que recebemos os dados em ordem natural (big-endian), ou seja, do MSB para o LSB e assim os atribuímos ao ponteiro. No entanto, olha o que recebemos como resultado:

Valor recebido: 16641

O que acontece aqui é justamente a aplicação do Endianness. Ao ler o conteúdo de received, como o processador utiliza a convenção little-endian, ele interpreta o valor de LSB para MSB.

Como atribuímos os valores manualmente no formato big-endian (MSB-LSB), a memória alocada fica desta maneira:

Dados em big-endian na memória

Porém, o little-endian “lê” primeiro 01000001 e depois 00000001. Como resultado, a composição destes dois binários resulta no decimal 16641, bem diferente do número 321 que esperávamos.

Como resolver?

A solução é relativamente simples: basta inverter a ordem dos bytes na memória. Podemos fazer isso de duas formas diferentes:

  • Atribuindo os pacotes na ordem correta conforme forem chegando do barramento, ou
  • Invertendo-os com operações bitwise após todos terem chegado

Apesar de menos intuitiva, a segunda opção é a mais simples e indicada para este cenário. Veja que somente uma linha de código (linha 15) resolve o problema:

int main(){
    // Armazena o valor 321
    uint16_t received = 0;
    // Para armazenar 8 bits por vez
    uint8_t *ptrReceived = (uint8_t *)&received;

    //Obtém o primeiro pacote (00000001) e armazena nos primeiros 8 bits de received
    *ptrReceived++ = read();
    //Obtém o segundo pacote (01000001) e armazena nos últimos 8 bits de received
    *ptrReceived = read();

    printf("Valor recebido: %" PRIu16 "\n", received);
    
    // Inverte o valor recebido com bitwise OR (apenas para 16 bits)
    received = received >> 8 | received << 8;
    printf("Novo valor: %" PRIu16 "\n", received);
    
    return 0;
}

O resultado será:

Valor recebido: 16641
Novo valor: 321

A linha 15 faz três operações: primeiramente desloca os bits da variável received oito posições à direita, em seguida desloca oito posições à esquerda e por fim aplica o bitwise OR.

Observe na imagem abaixo como essas operações funcionam:

Inversão de bytes

O deslocamento “empurra” uma determinada quantidade de bits em uma direção. No nosso caso, received >> 8 adiciona oito zeros à esquerda, empurrando todo o resto para direita.

A variável received ficaria mais ou menos assim: 00000000 00000001 01000001.

Os zeros em negrito (0000000) são os “novos” bits adicionados a received. Uma vez que received tem tamanho de 16 bits, o excedente em itálico (01000001) é descartado.

Portanto, o que fica é 00000000 00000001.

Por outro lado, received << 8 adiciona oito zeros à direita e “empurra” o conteúdo todo para esquerda. Dessa maneira, a sequência de bits ficaria desse jeito: 00000001 01000001 00000000.

Agora a parte descartada (00000001) fica à esquerda, produzindo então o resultado 01000000 00000000.

Por fim, sabendo que 0 OR 0 = 0, 0 OR 1 = 1, 1 OR 0 = 1 e 1 OR 1 = 1, temos que:

0000000000000001 | 0100000100000000 = 0100000100000001

A partir de agora, a variável received está reorganizada com a convenção little-endian.

Isso quer dizer que o processador interpretará primeiro o LSB (00000001) e depois o MSB (01000001) para formar de volta o número 321.

Exemplo protocolo I2C com Python

Como Python abstrai toda a complexidade do acesso manual à memória e simplifica até mesmo a tipagem de variáveis, as coisas agora serão bem mais simples.

Seguindo a mesma ideia do exemplo anterior em C/C++, considere o trecho abaixo que simula o envio de dados com o protocolo I2C:

from collections import deque

# 321 (00000001 01000001) em dois "pacotes" de 1 Byte
data = deque([0b00000001, 0b01000001])

def isDataAvaliable():
    return bool(data)

# Retorna 1 byte por vez, conforme protocolo I2C
def readData():
    return data.popleft()

Observe, em seguida, como é simples agrupar os bytes:

received = []
# Simula a leitura dos dados do barramento I2C
while(isDataAvaliable()):
    received.append(readData())

# Concatena os dados já na ordem correta
newValue = int.from_bytes(received, byteorder='big')
print(f"Valor:{newValue}")

O ponto chave neste exemplo é a função from_bytes(). Ela agrupa todos os bytes de uma estrutura de dados na ordem indicada pelo parâmetro byteorder.

Como não acessamos diretamente a memória em Python, não precisamos nos preocupar com a ordem em que os bytes são gravados ou lidos em cada posição.

Nós apenas informamos através do byteorder como os dados estão organizados dentro da estrutura, neste caso um list. O resto fica por conta da linguagem.

Caso o conteúdo do nosso list estivesse representado em little-endian, mudaríamos byteorder para ‘little’.

Após a execução deste código, temos como resultado o valor esperado:

Valor:321

Alternativamente, podemos substituir from_bytes() pelo método manual, com deslocamento e bitwise:

newValue = (received[0] << 8) | received[1]

No entanto, como em Python as variáveis não tem tamanho fixo como em C/C++, as operações de deslocamento de bits são um pouco mais difíceis de entender.

Como esse assunto foge do objetivo deste artigo, não vou entrar em detalhes de como Python se comporta neste cenário.

Sendo assim, apenas note que deslocamos somente o primeiro byte para esquerda e aplicamos o bitwise com o segundo byte sem o deslocamento.

Para o nosso propósito, é muito mais prático utilizar a função from_bytes() e nos empenharmos em outras tarefas mais interessantes!

Conclusão

Conforme pudemos ver, é importante compreender os fundamentos do protocolo I2C e como os bytes provenientes da transmissão ocupam a memória de acordo com o Endianness do processador.

O entendimento destes temas nos permite fazer a interpretação correta das informações geradas a partir de dispositivos I2C conectados à maioria dos microcontroladores e processadores existentes.

Apesar de um pouco mais complexo, o exemplo em C/C++ teve maior destaque neste artigo porque a linguagem é predominante nas plataformas de desenvolvimento como Arduino, Esp32, etc.

No entanto Python vem ganhando espaço neste segmento, seja através de versões reduzidas como MicroPython para Esp32 e Arduino, seja com Python tradicional para SBC’s como Raspberry Pi.

Desta forma, além de mais fácil, o exemplo Python também oferece uma boa maneira de entender o assunto.

Outro ponto importante diz respeito a diversidade do funcionamento dos dispositivos I2C existentes no mercado.

Embora o protocolo I2C padronize a comunicação entre os pontos, a forma como as informações são construídas não é padronizada.

Isso fica a critério dos fabricantes, que podem construí-las da forma que quiserem. As informações podem variar em tamanho, Endianness e outras características.

No exemplo deste artigo, abordei o assunto com uma mensagem big-endian fixa de 16 bits. Nada impede que uma informação venha com tamanho de 32 bits, por exemplo.

Portanto, sempre procure ler o datasheet do dispositivo que deseja interagir para saber como ele constrói suas mensagens e assim interpretá-las corretamente.

Espero ter ajudado!

Até a próxima!