Do Conceito ao Código: A Minha Jornada Iterativa para um Módulo de Multiplexagem com o ChatGPT
O prompting iterativo com o ChatGPT transformou um problema de hardware de uma linha num módulo multiplexor completo e não bloqueante para o ESP32 — este artigo mostra como isso funcionou, passo a passo. Começou com uma restrição simples: eu tinha um ESP32 com apenas um pino analógico acessível, mas precisava de ler 16 canais analógicos. Expliquei o meu problema ao modelo ChatGPT o3-mini-high: “Só tenho um canal, mas preciso de ler 16 canais.” O ChatGPT respondeu com várias opções — ADCs externos, multiplexagem e mais. Considerei as escolhas e decidi que um multiplexor analógico, especificamente o CD74HC4067, era o melhor caminho. Essa escolha abriu uma discussão muito mais profunda.
Passo 1: O Desejo Inicial
Comecei por afirmar que queria usar o ESP32 para ler 16 canais analógicos, embora só tivesse uma entrada analógica disponível. O ChatGPT forneceu rapidamente várias opções: multiplexores analógicos, ADCs externos e até a reatribuição de pinos, se possível. No fim, escolhi o multiplexor CD74HC4067. Nessa altura, ainda não tinha detalhado a arquitetura de software pretendida nem quaisquer requisitos de timing. Mais tarde, pedi possíveis desenhos de arquitetura e um conjunto de requisitos para suportar o meu objetivo.
Passo 2: Desenho da Arquitetura e Requisitos
Pedi então ao ChatGPT que propusesse um desenho de arquitetura para um módulo de software que fizesse a interface com o CD74HC4067. Apresentou um desenho em camadas que separava a abstração de hardware, o controlo do multiplexor, a leitura do ADC e a integração de tarefas. A estrutura era completa e organizada, mas reparei que faltavam detalhes sobre desempenho em tempo real e parametrização em tempo de compilação. Por isso, pedi que cada parâmetro ajustável fosse definido como uma diretiva de pré-processador e que o desenho incluísse uma abordagem de RTOS Task/Thread com uma queue para guardar um timestamp para cada conjunto de 16 leituras de canais.
Passo 3: Adicionar RTOS e Timestamps
Com a ideia agora a tomar forma claramente, confirmei que ia usar o CD74HC4067. Pedi especificamente que fosse criada uma tarefa RTOS dedicada para varrer sequencialmente os 16 canais e que cada conjunto completo de leituras incluísse um timestamp a indicar quando a leitura terminou. O ChatGPT atualizou o desenho em conformidade, incorporando uma camada de gestor de ADC e amostragem e especificando que o pacote de dados conteria tanto os dados dos canais como o timestamp. Nesse momento, senti que o desenho era sólido, mas ainda precisava de cálculos de timing mais específicos.
Passo 4: Cálculos de Timing e Modos de Amostragem
Em seguida, pedi um cálculo de quantos milissegundos demoraria a ler os 16 canais. O ChatGPT forneceu vários cenários de exemplo baseados em diferentes tempos de estabilização e conversão do ADC. Gostei da explicação, mas fui mais longe: queria adicionar um parâmetro (definido em tempo de compilação) que pudesse tomar valores como Fast Mode, Moderate Mode ou HighRes. Este parâmetro ajustaria automaticamente o tempo de estabilização do multiplexor e o atraso de conversão do ADC de acordo com os valores do datasheet e medições anteriores. O desenho evoluiu ainda mais com estes requisitos adicionados.
Passo 5: Implementação Completa do Módulo
Depois pedi um módulo completo — ficheiros .cpp e .h — que incorporasse todos os requisitos até aqui, incluindo funções de teste unitário. O ChatGPT produziu um módulo detalhado, com todos os parâmetros ajustáveis definidos como diretivas de pré-processador e valores de timing reais escolhidos com base nos dados do datasheet (por exemplo, usando um tempo de conversão de cerca de 11 µs para o ESP32). Até veio com testes unitários. O módulo era completo, mas reparei que o código ainda usava algumas esperas bloqueantes (por exemplo, chamadas como ets_delay_us), o que não era aceitável para as minhas necessidades de tempo real.
Passo 6: O Mandato Não Bloqueante
Por fim, insisti que não fossem usadas esperas bloqueantes em nenhuma parte do código: cada atraso de timing tinha de correr de forma não bloqueante usando uma abordagem de máquina de estados, com o código a ceder ao scheduler em vez de fazer busy-waiting. O ChatGPT reviu o desenho uma última vez, convertendo a tarefa de amostragem numa máquina de estados não bloqueante que verifica o tempo decorrido e cede o controlo usando vTaskDelay(0) e taskYIELD(). Com isso, o desenho ficou completo e cumpriu todos os meus requisitos.
Reflexões sobre a IA como Arquiteto de Sistemas, Programador e Desenvolvedor de Hardware
Olhando para trás, o caminho de uma simples descrição de problema até um módulo completo e não bloqueante ensinou-me onde a IA realmente ajuda. Através de conversação iterativa, uma ideia vaga tornou-se um conjunto detalhado de requisitos e uma implementação completa. A IA pode falhar nuances subtis de hardware ou problemas de integração, mas a iteração rápida permitiu-me gastar o meu tempo a refinar o desenho em vez de reinventar cada detalhe.
A minha conclusão: a IA pode atuar tanto como arquiteto de sistemas como programador. Pode sugerir desenhos modulares, ajudar a definir requisitos funcionais e não funcionais e gerar código que respeita restrições de tempo real e não bloqueantes. A supervisão humana continua a ser essencial para verificar e adaptar o resultado da IA ao hardware do mundo real. Mas o fluxo de trabalho avança mais depressa, deixando mais tempo para testes, otimização e resolução criativa de problemas.
Pode ver os requisitos e o código completo abaixo.
Requisitos Funcionais:
-
Inicialização de Hardware: O módulo deve inicializar todos os pinos digitais do ESP32 para controlar o multiplexor CD74HC4067 e configurar corretamente o ADC.
-
Seleção de Canal do Multiplexor: O módulo deve permitir a seleção de qualquer canal (0–15) através de saídas digitais.
-
Leitura do ADC: O módulo deve realizar conversões ADC a partir da saída do multiplexor.
-
Tarefa RTOS para Amostragem: Uma tarefa FreeRTOS dedicada e não bloqueante deve amostrar sequencialmente os 16 canais.
-
Empacotamento de Dados: Após um varrimento completo, o módulo deve empacotar as 16 leituras do ADC juntamente com um timestamp (em milissegundos) num pacote de dados.
-
Integração com Queue: Cada pacote de dados deve ser colocado numa queue FreeRTOS para processamento assíncrono.
-
Parametrização: Cada parâmetro ajustável (intervalo de amostragem, atraso de conversão do ADC, atraso de estabilização do multiplexor, comprimento da queue, atribuições de pinos, etc.) deve ser definido como uma diretiva de pré-processador.
-
Parâmetro de Modo de Amostragem: O módulo deve suportar um parâmetro selecionável em tempo de compilação (MODE_FAST, MODE_MODERATE ou MODE_HIGHRES) que ajusta as definições de timing (como o tempo de estabilização e o atraso de conversão do ADC) com base nos dados do datasheet e em medições práticas.
-
Operação Não Bloqueante: Todos os atrasos de timing devem ser implementados de forma não bloqueante (usando um desenho de máquina de estados e cedendo ao scheduler), sem esperas bloqueantes.
-
Gestão de Erros: O módulo deve detetar e reportar erros (como falhas de inicialização de hardware e overflows da queue).
Requisitos Não Funcionais:
-
Desempenho em Tempo Real: A tarefa de amostragem deve operar com jitter mínimo e ter prioridade suficiente atribuída.
-
Modularidade e Manutenibilidade: O desenho deve separar o controlo de hardware, a lógica de amostragem e a interface com o RTOS para facilitar a manutenção.
-
Eficiência: O módulo deve conservar ciclos de CPU evitando operações bloqueantes.
-
Escalabilidade: O desenho deve permitir melhorias futuras (por exemplo, filtragem ou média adicionais) com alterações mínimas.
-
Testabilidade: A funcionalidade do módulo deve ser verificável através de testes unitários.
Código Final
Abaixo está o código completo do módulo final:
MultiplexerModule.h
cpp
#ifndef MULTIPLEXER_MODULE_H
#define MULTIPLEXER_MODULE_H
#include <Arduino.h>
#include <freertos/FreeRTOS.h>
#include <freertos/queue.h>
#include <freertos/task.h>
#include <stdint.h>
/*
Module Parameters and Sampling Modes
All adjustable parameters are defined as preprocessor directives.
The values below are chosen based on published datasheet data and practical measurements.
*/
// Number of multiplexer channels.
#ifndef NUM_CHANNELS
#define NUM_CHANNELS 16
#endif
// Sampling mode selection.
typedef enum {
MODE_FAST,
MODE_MODERATE,
MODE_HIGHRES
} SamplingMode_t;
#ifndef SAMPLING_MODE
#define SAMPLING_MODE MODE_FAST
#endif
// Timing parameters (in microseconds) based on sampling mode.
#if (SAMPLING_MODE == MODE_FAST)
#define MUX_SETTLE_TIME_US 2 // Minimal settling delay.
#define ADC_CONVERSION_TIME_US 11 // Measured ~11 µs conversion time.
#elif (SAMPLING_MODE == MODE_MODERATE)
#define MUX_SETTLE_TIME_US 5
#define ADC_CONVERSION_TIME_US 22
#elif (SAMPLING_MODE == MODE_HIGHRES)
#define MUX_SETTLE_TIME_US 20
#define ADC_CONVERSION_TIME_US 50
#else
#error "Invalid SAMPLING_MODE selected"
#endif
// Full-scan sampling interval (in milliseconds).
#ifndef SAMPLING_INTERVAL_MS
#define SAMPLING_INTERVAL_MS 10 // Adjust as required.
#endif
// Queue length for data packages.
#ifndef QUEUE_LENGTH
#define QUEUE_LENGTH 10
#endif
// Hardware pin assignments (adjust as needed for your wiring).
#ifndef MUX_S0_PIN
#define MUX_S0_PIN 25
#endif
#ifndef MUX_S1_PIN
#define MUX_S1_PIN 26
#endif
#ifndef MUX_S2_PIN
#define MUX_S2_PIN 27
#endif
#ifndef MUX_S3_PIN
#define MUX_S3_PIN 14
#endif
#ifndef MUX_EN_PIN
#define MUX_EN_PIN 12 // Optional enable pin (active LOW). If unused, tie low.
#endif
#ifndef MUX_ANALOG_PIN
#define MUX_ANALOG_PIN 34 // Example: an ADC1 channel on the ESP32.
#endif
// Data package structure that holds a full set of readings with a timestamp.
typedef struct {
uint16_t channelReadings[NUM_CHANNELS]; // ADC reading for each channel.
uint32_t timestamp_ms; // Timestamp (in ms) when the scan finished.
} DataPackage_t;
// Public API class declaration.
class MultiplexerModule {
public:
// Initialize the module: configure hardware, create queue, and start the sampling task.
static void init();
// Stop the sampling task and free allocated resources.
static void stop();
// Retrieve a data package from the module’s queue (non-blocking).
static bool getData(DataPackage_t* pkg, TickType_t waitTime = 0);
// (Optional) Set sampling mode at runtime.
// NOTE: Dynamic mode changes are not supported because all timing parameters are compile-time.
static void setSamplingMode(SamplingMode_t mode);
// Unit test function (compiled only if UNIT_TEST is defined).
#ifdef UNIT_TEST
static void runUnitTests();
#endif
private:
// Enumeration of states in the non-blocking sampling state machine.
typedef enum {
STATE_SELECT_CHANNEL,
STATE_WAIT_SETTLE,
STATE_ADC_READ,
STATE_WAIT_ADC,
STATE_NEXT_CHANNEL,
STATE_FULL_SCAN_WAIT
} SamplingState_t;
// The non-blocking state-machine-based sampling task.
static void samplingTask(void* pvParameters);
// Helper function to set the multiplexer to the specified channel.
static void selectMuxChannel(uint8_t channel);
// FreeRTOS queue handle for data packages.
static QueueHandle_t muxQueue;
// Task handle for the sampling task.
static TaskHandle_t samplingTaskHandle;
// Current sampling mode (for documentation; timing parameters remain compile-time).
static SamplingMode_t currentMode;
};
#endif // MULTIPLEXER_MODULE_H
MultiplexerModule.cpp
cpp
#include "MultiplexerModule.h"
// Static member definitions.
QueueHandle_t MultiplexerModule::muxQueue = NULL;
TaskHandle_t MultiplexerModule::samplingTaskHandle = NULL;
SamplingMode_t MultiplexerModule::currentMode = SAMPLING_MODE;
// Helper function: set multiplexer control pins based on the channel (0–15).
void MultiplexerModule::selectMuxChannel(uint8_t channel) {
digitalWrite(MUX_S0_PIN, (channel & 0x01) ? HIGH : LOW);
digitalWrite(MUX_S1_PIN, (channel & 0x02) ? HIGH : LOW);
digitalWrite(MUX_S2_PIN, (channel & 0x04) ? HIGH : LOW);
digitalWrite(MUX_S3_PIN, (channel & 0x08) ? HIGH : LOW);
}
// Non-blocking sampling task implemented as a state machine.
void MultiplexerModule::samplingTask(void* pvParameters) {
DataPackage_t pkg;
uint8_t currentChannel = 0;
SamplingState_t state = STATE_SELECT_CHANNEL;
uint64_t stateStartTime = esp_timer_get_time(); // in microseconds
uint64_t fullScanStartTime = esp_timer_get_time();
uint16_t adcTemp = 0;
// Run the state machine forever without blocking waits.
for (;;) {
uint64_t now = esp_timer_get_time();
switch (state) {
case STATE_SELECT_CHANNEL:
selectMuxChannel(currentChannel);
digitalWrite(MUX_EN_PIN, LOW);
stateStartTime = now;
state = STATE_WAIT_SETTLE;
break;
case STATE_WAIT_SETTLE:
if (now - stateStartTime >= MUX_SETTLE_TIME_US) {
state = STATE_ADC_READ;
} else {
vTaskDelay(0);
}
break;
case STATE_ADC_READ:
adcTemp = analogRead(MUX_ANALOG_PIN);
stateStartTime = now;
state = STATE_WAIT_ADC;
break;
case STATE_WAIT_ADC:
if (now - stateStartTime >= ADC_CONVERSION_TIME_US) {
pkg.channelReadings[currentChannel] = adcTemp;
state = STATE_NEXT_CHANNEL;
} else {
vTaskDelay(0);
}
break;
case STATE_NEXT_CHANNEL:
currentChannel++;
if (currentChannel < NUM_CHANNELS) {
state = STATE_SELECT_CHANNEL;
} else {
pkg.timestamp_ms = (uint32_t)((esp_timer_get_time() - fullScanStartTime) / 1000);
xQueueSend(muxQueue, &pkg, 0);
currentChannel = 0;
fullScanStartTime = esp_timer_get_time();
state = STATE_FULL_SCAN_WAIT;
}
break;
case STATE_FULL_SCAN_WAIT: {
uint32_t elapsed_ms = (uint32_t)((esp_timer_get_time() - fullScanStartTime) / 1000);
if (elapsed_ms >= SAMPLING_INTERVAL_MS) {
state = STATE_SELECT_CHANNEL;
} else {
vTaskDelay(0);
}
}
break;
default:
state = STATE_SELECT_CHANNEL;
break;
}
taskYIELD();
}
}
// Public initialization: configure pins, create the queue, and start the sampling task.
void MultiplexerModule::init() {
pinMode(MUX_S0_PIN, OUTPUT);
pinMode(MUX_S1_PIN, OUTPUT);
pinMode(MUX_S2_PIN, OUTPUT);
pinMode(MUX_S3_PIN, OUTPUT);
pinMode(MUX_EN_PIN, OUTPUT);
digitalWrite(MUX_EN_PIN, LOW);
pinMode(MUX_ANALOG_PIN, INPUT);
muxQueue = xQueueCreate(QUEUE_LENGTH, sizeof(DataPackage_t));
if (muxQueue == NULL) {
Serial.println("Error: Could not create multiplexer queue.");
return;
}
BaseType_t result = xTaskCreatePinnedToCore(
samplingTask,
"MuxSamplingTask",
4096,
NULL,
1,
&samplingTaskHandle,
1
);
if (result != pdPASS) {
Serial.println("Error: Could not create sampling task.");
}
}
// Public stop function.
void MultiplexerModule::stop() {
if (samplingTaskHandle != NULL) {
vTaskDelete(samplingTaskHandle);
samplingTaskHandle = NULL;
}
if (muxQueue != NULL) {
vQueueDelete(muxQueue);
muxQueue = NULL;
}
}
// Retrieve a data package from the queue (non-blocking).
bool MultiplexerModule::getData(DataPackage_t* pkg, TickType_t waitTime) {
if (muxQueue == NULL) return false;
return (xQueueReceive(muxQueue, pkg, waitTime) == pdPASS);
}
// Public function to set the sampling mode (runtime changes are not supported).
void MultiplexerModule::setSamplingMode(SamplingMode_t mode) {
Serial.println("Runtime mode change not supported; set SAMPLING_MODE at compile time.");
}
#ifdef UNIT_TEST
// Unit tests.
void MultiplexerModule::runUnitTests() {
Serial.println("Running unit tests for MultiplexerModule...");
for (uint8_t ch = 0; ch < NUM_CHANNELS; ch++) {
bool b0 = (ch & 0x01) != 0;
bool b1 = (ch & 0x02) != 0;
bool b2 = (ch & 0x04) != 0;
bool b3 = (ch & 0x08) != 0;
Serial.print("Channel ");
Serial.print(ch);
Serial.print(" expects S0=");
Serial.print(b0 ? "HIGH" : "LOW");
Serial.print(", S1=");
Serial.print(b1 ? "HIGH" : "LOW");
Serial.print(", S2=");
Serial.print(b2 ? "HIGH" : "LOW");
Serial.print(", S3=");
Serial.println(b3 ? "HIGH" : "LOW");
}
uint32_t expected_us = NUM_CHANNELS * (MUX_SETTLE_TIME_US + ADC_CONVERSION_TIME_US);
Serial.print("Expected full-scan time (µs): ");
Serial.println(expected_us);
if (sizeof(DataPackage_t) != (NUM_CHANNELS * sizeof(uint16_t) + sizeof(uint32_t))) {
Serial.println("Error: DataPackage_t size mismatch.");
} else {
Serial.println("DataPackage_t size verified.");
}
Serial.println("Unit tests completed.");
}
#endif // UNIT_TEST