От идеи до кода: мой итеративный путь к модулю мультиплексирования с 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 движется быстрее, оставляя больше времени на тестирование, оптимизацию и творческое решение проблем.
Ниже вы можете увидеть требования и полный код.
Функциональные требования:
-
Hardware Initialization: модуль должен инициализировать все цифровые пины ESP32 для управления мультиплексором CD74HC4067 и правильно конфигурировать ADC.
-
Multiplexer Channel Selection: модуль должен позволять выбирать любой канал (0–15) через цифровые выходы.
-
ADC Reading: модуль должен выполнять ADC-преобразования с выхода мультиплексора.
-
RTOS Task for Sampling: выделенная non-blocking задача FreeRTOS должна последовательно семплировать все 16 каналов.
-
Data Packaging: после полного скана модуль должен упаковывать 16 ADC-показаний вместе с временной меткой (в миллисекундах) в пакет данных.
-
Queue Integration: каждый пакет данных должен отправляться в очередь FreeRTOS для асинхронной обработки.
-
Parameterization: каждый регулируемый параметр (sampling interval, ADC conversion delay, multiplexer settling delay, queue length, назначения пинов и т.д.) должен быть определён как preprocessor directive.
-
Sampling Mode Parameter: модуль должен поддерживать compile-time выбираемый параметр (
MODE_FAST,MODE_MODERATEилиMODE_HIGHRES), который корректирует настройки тайминга (такие как settling time и ADC conversion delay) на основе данных datasheet и практических измерений. -
Non-Blocking Operation: все временные задержки должны реализовываться non-blocking образом (с использованием дизайна state-machine и отдачей управления scheduler’у) без blocking waits.
-
Error Handling: модуль должен обнаруживать и сообщать об ошибках (таких как сбои инициализации hardware и переполнение очереди).
Нефункциональные требования:
-
Real-Time Performance: sampling task должна работать с минимальным jitter и иметь достаточный приоритет.
-
Modularity & Maintainability: дизайн должен разделять hardware control, sampling logic и RTOS interfacing для упрощения поддержки.
-
Efficiency: модуль должен экономить CPU cycles, избегая blocking-операций.
-
Scalability: дизайн должен позволять будущие улучшения (например, дополнительную фильтрацию или усреднение) с минимальными изменениями.
-
Testability: функциональность модуля должна быть проверяемой через unit-тесты.
Финальный код
Ниже приведён полный финальный код модуля:
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