Tohum

От идеи до кода: мой итеративный путь к модулю мультиплексирования с ChatGPT

Иллюстрация: мультиплексор направляет много входов в один выход, оформленный итеративным диалогом

Итеративный промптинг с ChatGPT превратил аппаратную проблему длиной в одну строку в полный non-blocking модуль мультиплексора для ESP32 — этот пост шаг за шагом показывает, как это работало. Началось всё с прямолинейного ограничения: у меня был ESP32 с доступным всего одним аналоговым пином, но мне нужно было считывать 16 аналоговых каналов. Я объяснил свою ситуацию модели ChatGPT o3-mini-high: «У меня только один канал, но мне нужно считывать 16 каналов». ChatGPT ответил несколькими вариантами — внешние ADC, мультиплексирование и другие. Я рассмотрел варианты и решил, что аналоговый мультиплексор, а именно CD74HC4067, — лучший путь. Этот выбор открыл гораздо более глубокое обсуждение.

Шаг 1: Первоначальное желание

Я начал с того, что заявил о желании использовать ESP32 для чтения 16 аналоговых каналов, хотя у меня был доступен только один аналоговый вход. ChatGPT быстро предложил несколько вариантов: аналоговые мультиплексоры, внешние ADC и даже переназначение пинов, если возможно. В итоге я выбрал мультиплексор CD74HC4067. В тот момент я ещё не детализировал желаемую software-архитектуру или какие-либо требования по таймингу. Позже я попросил возможные архитектурные дизайны и набор требований для поддержки моей цели.

Шаг 2: Архитектурный дизайн и требования

Затем я попросил ChatGPT придумать архитектурный дизайн software-модуля, который будет взаимодействовать с CD74HC4067. Он представил многоуровневый дизайн, разделивший hardware abstraction, управление мультиплексором, чтение ADC и интеграцию задач. Структура была тщательной и организованной, но я заметил, что в ней не хватает деталей о real-time производительности и compile-time параметризации. Поэтому я запросил, чтобы каждый регулируемый параметр был определён как preprocessor directive, а дизайн включал подход RTOS Task/Thread с очередью для хранения временной метки для каждого набора из 16 показаний.

Шаг 3: Добавление RTOS и временных меток

Теперь, когда идея явно обретала форму, я подтвердил, что буду использовать CD74HC4067. Я конкретно попросил создать выделенную RTOS-задачу для последовательного сканирования всех 16 каналов и чтобы каждый полный набор показаний включал временную метку, указывающую, когда чтение было завершено. ChatGPT обновил дизайн соответственно, включив слой ADC & sampling manager и указав, что пакет данных будет содержать как данные каналов, так и временную метку. На этом этапе я чувствовал, что дизайн прочный, но всё ещё нуждается в более конкретных расчётах тайминга.

Шаг 4: Расчёты тайминга и режимы семплирования

Далее я попросил рассчитать, сколько миллисекунд займёт чтение всех 16 каналов. ChatGPT предоставил несколько примерных сценариев на основе различных задержек settling и ADC conversion. Мне понравилось объяснение, но затем я пошёл дальше: я хотел добавить параметр (задаваемый на этапе компиляции), который мог бы принимать значения вроде Fast Mode, Moderate Mode или HighRes. Этот параметр автоматически корректировал бы settling time мультиплексора и ADC conversion delay в соответствии с цифрами из datasheet и предыдущими измерениями. Дизайн эволюционировал дальше с этими добавленными требованиями.

Шаг 5: Полная реализация модуля

Затем я запросил полный модуль — и .cpp, и .h файлы — включающий все требования на тот момент, включая unit-test функции. ChatGPT произвёл детальный модуль, где все регулируемые параметры определены как preprocessor directives, а реальные значения тайминга выбраны на основе данных datasheet (например, conversion time около 11 µs для ESP32). Он даже шёл с unit-тестами. Модуль был тщательным, но я заметил, что код всё ещё использовал некоторые blocking waits (например, вызовы вроде ets_delay_us), что было неприемлемо для моих real-time нужд.

Шаг 6: Мандат на non-blocking

Наконец, я настоял, чтобы в коде нигде не использовались blocking waits: каждая временная задержка должна была выполняться non-blocking образом с использованием подхода state-machine, при котором код отдаёт управление scheduler’у вместо busy-waiting. ChatGPT в последний раз пересмотрел дизайн, превратив sampling task в non-blocking state machine, которая проверяет прошедшее время и отдаёт управление с помощью vTaskDelay(0) и taskYIELD(). С этим дизайн был завершён и соответствовал всем моим требованиям.


Размышления об ИИ как системном архитекторе, разработчике и hardware-разработчике

Оглядываясь назад: путь от простой формулировки проблемы до полного non-blocking модуля научил меня тому, где ИИ действительно помогает. Через итеративный разговор расплывчатая идея стала детальным набором требований и полной реализацией. ИИ может упускать тонкие hardware-нюансы или интеграционные проблемы, но быстрая итерация позволила мне тратить время на доработку дизайна, а не на изобретение каждой детали заново.

Мой вывод: ИИ может выступать и системным архитектором, и разработчиком. Он может предлагать модульные дизайны, помогать определять функциональные и нефункциональные требования и генерировать код, соответствующий real-time, non-blocking ограничениям. Человеческий надзор остаётся необходимым для проверки и адаптации вывода ИИ к реальному железу. Но workflow движется быстрее, оставляя больше времени на тестирование, оптимизацию и творческое решение проблем.

Ниже вы можете увидеть требования и полный код.


Функциональные требования:

Нефункциональные требования:


Финальный код

Ниже приведён полный финальный код модуля:

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