Tohum

از مفهوم تا کد: سفر تکرارشونده من به ماژول Multiplexing با ChatGPT

تصویرسازی: مالتی‌پلکسری چند ورودی را به یک خروجی می‌رساند و در گفت‌وگویی تکرارشونده شکل می‌گیرد

پرس‌وپاسخ تکرارشونده با ChatGPT یک مشکل سخت‌افزاری یک‌خطی را به یک ماژول multiplexer کامل و غیرمسدودکننده برای ESP32 تبدیل کرد—این پست قدم‌به‌قدم نشان می‌دهد آن فرآیند چطور کار کرد. همه‌اش با یک محدودیت سرراست شروع شد: یک ESP32 داشتم با فقط یک پین آنالوگ در دسترس، اما باید 16 کانال آنالوگ می‌خواندم. مشکلم را برای مدل o3-mini-high در ChatGPT توضیح دادم: «من فقط یک کانال دارم، اما باید 16 کانال را بخوانم.» ChatGPT با چند گزینه پاسخ داد—ADCهای خارجی، multiplexing و بیشتر. گزینه‌ها را سنجیدم و تصمیم گرفتم یک multiplexer آنالوگ، به‌طور خاص CD74HC4067، بهترین مسیر است. آن انتخاب بحث خیلی عمیق‌تری را باز کرد.

قدم 1: آرزوی اولیه

شروع کردم با اینکه گفتم می‌خواهم از ESP32 برای خواندن 16 کانال آنالوگ استفاده کنم، با اینکه فقط یک ورودی آنالوگ در دسترس داشتم. ChatGPT سریع چند گزینه ارائه داد: multiplexerهای آنالوگ، ADCهای خارجی و حتی تخصیص‌دوباره پین‌ها در صورت امکان. در نهایت، multiplexer CD74HC4067 را انتخاب کردم. در آن نقطه، هنوز معماری نرم‌افزار دلخواهم یا نیازمندی‌های زمانی‌ام را جزئی نکرده بودم. بعد، درخواست طراحی‌های معماری ممکن و مجموعه‌ای از نیازمندی‌ها برای پشتیبانی از هدفم را کردم.

قدم 2: طراحی معماری و نیازمندی‌ها

بعد از ChatGPT خواستم یک طراحی معماری برای یک ماژول نرم‌افزاری ارائه دهد که با CD74HC4067 واسط شود. یک طراحی لایه‌ای ارائه داد که abstraction سخت‌افزار، کنترل multiplexer، خواندن ADC و یکپارچه‌سازی تسک را از هم جدا می‌کرد. ساختار کامل و منظم بود، اما متوجه شدم جزئیات عملکرد real-time و پارامتردهی در زمان کامپایل را ندارد. پس درخواست کردم که هر پارامتر قابل تنظیم به‌عنوان یک directive پیش‌پردازنده تعریف شود، و طراحی شامل یک رویکرد RTOS Task/Thread با یک صف برای نگه‌داشتن timestamp برای هر مجموعه از 16 قرائت کانال باشد.

قدم 3: افزودن RTOS و Timestampها

حالا که ایده به‌وضوح شکل می‌گرفت، تأیید کردم که قرار است از CD74HC4067 استفاده کنم. به‌طور خاص خواستم یک تسک RTOS اختصاصی ساخته شود که هر 16 کانال را به‌ترتیب اسکن کند و هر مجموعه کامل قرائت‌ها شامل یک timestamp باشد که نشان دهد خواندن چه وقت تمام شده. ChatGPT طراحی را متناسب با آن به‌روز کرد، یک لایه مدیر ADC و نمونه‌برداری را وارد کرد و مشخص کرد که بسته داده هم داده کانال و هم timestamp را در بر می‌گیرد. در این نقطه، حس کردم طراحی محکم است اما هنوز به محاسبات زمانی مشخص‌تری نیاز دارد.

قدم 4: محاسبات زمانی و حالت‌های نمونه‌برداری

بعد، محاسبه این را خواستم که خواندن هر 16 کانال چند میلی‌ثانیه طول می‌کشد. ChatGPT چند سناریوی مثال بر اساس تأخیرهای مختلف settling و تبدیل ADC ارائه داد. توضیح را دوست داشتم اما بعد جلوتر رفتم: می‌خواستم یک پارامتر (تنظیم‌شده در زمان کامپایل) اضافه کنم که بتواند مقادیری مثل Fast Mode، Moderate Mode یا HighRes بگیرد. این پارامتر به‌طور خودکار زمان settling مالتی‌پلکسر و تأخیر تبدیل ADC را بر اساس ارقام دیتاشیت و اندازه‌گیری‌های قبلی تنظیم می‌کند. طراحی با این نیازمندی‌های اضافه‌شده بیشتر تکامل یافت.

قدم 5: پیاده‌سازی کامل ماژول

بعد یک ماژول کامل خواستم—هم فایل‌های .cpp و هم .h—که همه نیازمندی‌های تاکنونی را در بر بگیرد، شامل تابع‌های unit test. ChatGPT یک ماژول مفصل تولید کرد، با همه پارامترهای قابل تنظیم تعریف‌شده به‌عنوان directiveهای پیش‌پردازنده و مقادیر زمانی واقعی انتخاب‌شده بر اساس داده دیتاشیت (مثلاً استفاده از زمان تبدیل حدود 11 µs برای ESP32). حتی با unit testها آمد. ماژول کامل بود، اما متوجه شدم کد هنوز از بعضی انتظارهای مسدودکننده استفاده می‌کند (مثلاً صداهايي مثل ets_delay_us)، که برای نیازهای real-time من قابل‌قبول نبود.

قدم 6: الزام غیرمسدودکننده

سرانجام، اصرار کردم که هیچ انتظار مسدودکننده‌ای در هیچ‌جای کد استفاده نشود: هر تأخیر زمانی باید به‌شکل غیرمسدودکننده با رویکرد state-machine اجرا شود، و کد به‌جای busy-waiting به scheduler کنترل بسپارد. ChatGPT طراحی را یک بار آخر بازبینی کرد و تسک نمونه‌برداری را به یک state machine غیرمسدودکننده تبدیل کرد که زمان سپری‌شده را چک می‌کند و با vTaskDelay(0) و taskYIELD() کنترل را واگذار می‌کند. با آن، طراحی کامل شد و همه نیازمندی‌هایم را برآورده کرد.


تأملاتی بر AI به‌عنوان معمار سیستم، توسعه‌دهنده و توسعه‌دهنده سخت‌افزار

با نگاه به عقب، مسیر از یک بیان مسئله ساده به یک ماژول کامل و غیرمسدودکننده به من آموخت که AI واقعاً کجا کمک می‌کند. از طریق مکالمه تکرارشونده، یک ایده مبهم به مجموعه‌ای مفصل از نیازمندی‌ها و یک پیاده‌سازی کامل تبدیل شد. AI می‌تواند ظرافت‌های ظریف سخت‌افزار یا مشکلات یکپارچه‌سازی را از دست بدهد، اما تکرار سریع به من اجازه داد وقت‌ام را صرف پالایش طراحی کنم به‌جای اختراع‌دوباره هر جزئیات.

نتیجه‌ای که گرفتم: AI می‌تواند هم به‌عنوان معمار سیستم و هم به‌عنوان توسعه‌دهنده عمل کند. می‌تواند طراحی‌های ماژولار پیشنهاد دهد، به تعریف نیازمندی‌های عملکردی و غیرعملکردی کمک کند و کدی تولید کند که به محدودیت‌های real-time و غیرمسدودکننده پایبند باشد. نظارت انسانی برای راستی‌آزمایی و تطبیق خروجی AI با سخت‌افزار دنیای واقعی ضروری می‌ماند. اما 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