من الفكرة إلى الكود: رحلتي التكرارية إلى وحدة Multiplexing مع ChatGPT
حوّل التوجيه التكراري مع ChatGPT مشكلة عتادية من سطر واحد إلى وحدة multiplexer كاملة وغير حاجبة (non-blocking) لـ ESP32 — وهذا المقال يشرح كيف تم ذلك خطوة بخطوة. بدأ الأمر بقيد بسيط: كان لديّ ESP32 بمنفذ تماثلي واحد فقط متاح، لكنني كنت بحاجة إلى قراءة 16 قناة تماثلية. شرحت مشكلتي لنموذج ChatGPT o3-mini-high: «لديّ قناة واحدة فقط، لكنني أحتاج إلى قراءة 16 قناة». أجاب ChatGPT بعدة خيارات — ADCs خارجية، وmultiplexing، وغيرها. قيّمت الخيارات وقررت أن analog multiplexer، وتحديداً CD74HC4067، هو الطريق الأفضل. ذلك الاختيار فتح نقاشاً أعمق بكثير.
الخطوة 1: الرغبة الأولى
بدأت بأنني أريد استخدام ESP32 لقراءة 16 قناة تماثلية رغم أن لديّ مدخلاً تماثلياً واحداً فقط متاحاً. قدّم ChatGPT بسرعة عدة خيارات: analog multiplexers، وADCs خارجية، وحتى إعادة تعيين المنافذ إن أمكن. في النهاية، اخترت CD74HC4067 multiplexer. عند تلك النقطة، لم أكن قد فصّلت بعد معمارية البرمجيات التي أريدها أو أي متطلبات زمنية. لاحقاً، طلبت تصاميم معمارية ممكنة ومجموعة متطلبات تدعم هدفي.
الخطوة 2: التصميم المعماري والمتطلبات
طلبت بعدها من ChatGPT أن يبتكر تصميماً معمارياً لوحدة برمجية تتعامل مع CD74HC4067. قدّم تصميماً متعدد الطبقات يفصل بين تجريد العتاد، والتحكم في الـ multiplexer، وقراءة ADC، وتكامل المهام. كان الهيكل شاملاً ومنظماً، لكنني لاحظت أنه ينقصه تفاصيل الأداء الزمني الحقيقي (real-time) والمعاملة بالمعطيات في وقت الترجمة. لذا طلبت أن يُعرَّف كل معامل قابل للضبط كتوجيه preprocessor، وأن يتضمن التصميم نهج RTOS Task/Thread مع queue تحتفظ بطابع زمني لكل مجموعة من قراءات القنوات الـ 16.
الخطوة 3: إضافة RTOS والطوابع الزمنية
ومع اتضاح الفكرة الآن، أكدت أنني سأستخدم CD74HC4067. طلبت تحديداً إنشاء مهمة RTOS مخصصة تمسح القنوات الـ 16 تباعاً، وأن تتضمن كل مجموعة قراءات كاملة طابعاً زمنياً يشير إلى متى انتهت القراءة. حدّث ChatGPT التصميم وفقاً لذلك، مضيفاً طبقة مدير ADC وأخذ العينات، ومحدداً أن حزمة البيانات ستحتوي على بيانات القنوات والطابع الزمني معاً. عند هذه النقطة، شعرت أن التصميم متين لكنه لا يزال بحاجة إلى حسابات زمنية أدق.
الخطوة 4: الحسابات الزمنية وأنماط أخذ العينات
بعدها، طلبت حساب عدد الميلي ثانية اللازمة لقراءة القنوات الـ 16 كلها. قدّم ChatGPT عدة سيناريوهات مثالية بناءً على أزمنة استقرار (settling) وتأخيرات تحويل ADC مختلفة. أعجبني الشرح لكنني دفعت أبعد: أردت إضافة معامل (يُضبط في وقت الترجمة) يمكن أن يأخذ قيماً مثل Fast Mode أو Moderate Mode أو HighRes. كان هذا المعامل سيضبط تلقائياً زمن استقرار الـ multiplexer وتأخير تحويل ADC وفقاً لأرقام الـ datasheet وقياسات سابقة. تطور التصميم أكثر مع هذه المتطلبات المضافة.
الخطوة 5: تطبيق الوحدة الكامل
ثم طلبت وحدة كاملة — ملفي .cpp و.h معاً — تتضمن كل المتطلبات حتى تلك اللحظة، بما فيها دوال اختبار الوحدة. أنتج ChatGPT وحدة مفصلة، بكل المعاملات القابلة للضبط معرَّفة كتوجيهات preprocessor وقيم زمنية حقيقية مختارة بناءً على بيانات الـ datasheet (على سبيل المثال، استخدام زمن تحويل يقارب 11 µs لـ ESP32). بل وجاءت مع اختبارات وحدة. كانت الوحدة شاملة، لكنني لاحظت أن الكود لا يزال يستخدم بعض الانتظارات الحاجبة (مثل استدعاءات ets_delay_us)، وهو ما لم يكن مقبولاً لاحتياجاتي الزمنية الحقيقية.
الخطوة 6: فرض عدم الحجب
أخيراً، أصريت على ألا تُستخدم أي انتظارات حاجبة في أي مكان في الكود: كان على كل تأخير زمني أن يعمل بطريقة غير حاجبة باستخدام نهج آلة الحالات (state-machine)، مع تسليم الكود التحكم للمجدوِل (scheduler) بدلاً من الانتظار المشغول. راجع ChatGPT التصميم مرة أخيرة، محوّلاً مهمة أخذ العينات إلى آلة حالات غير حاجبة تتحقق من الوقت المنقضي وتسلّم التحكم باستخدام vTaskDelay(0) وtaskYIELD(). وبذلك اكتمل التصميم واستوفى كل متطلباتي.
تأملات في الذكاء الاصطناعي كمعماري نظم ومطوّر ومطوّر عتاد
بالنظر إلى الوراء، علّمني الطريق من عبارة مشكلة بسيطة إلى وحدة كاملة غير حاجبة أين يساعد الذكاء الاصطناعي فعلاً. عبر المحادثة التكرارية، تحولت فكرة غامضة إلى مجموعة متطلبات مفصلة وتطبيق كامل. قد يفوت الذكاء الاصطناعي فروقاً عتادية دقيقة أو مشكلات تكامل، لكن التكرار السريع أتاح لي قضاء وقتي في تنقية التصميم بدلاً من إعادة اختراع كل تفصيلة.
خلاصتي: يستطيع الذكاء الاصطناعي أن يعمل كمعماري نظم وكمطوّر في آن واحد. يستطيع اقتراح تصاميم معيارية، والمساعدة في تعريف المتطلبات الوظيفية وغير الوظيفية، وتوليد كود يلتزم بقيود الزمن الحقيقي وعدم الحجب. يبقى الإشراف البشري ضرورياً للتحقق من مخرجات الذكاء الاصطناعي وتكييفها مع العتاد الحقيقي. لكن سير العمل يتحرك أسرع، تاركاً وقتاً أكبر للاختبار والتحسين وحل المشكلات الإبداعي.
يمكنك الاطلاع على المتطلبات والكود الكامل أدناه.
المتطلبات الوظيفية:
-
تهيئة العتاد: يجب على الوحدة تهيئة كل منافذ ESP32 الرقمية للتحكم في CD74HC4067 multiplexer وضبط ADC بشكل صحيح.
-
اختيار قناة الـ Multiplexer: يجب على الوحدة تمكين اختيار أي قناة (0–15) عبر المخرجات الرقمية.
-
قراءة ADC: يجب على الوحدة إجراء تحويلات ADC من خرج الـ multiplexer.
-
مهمة RTOS لأخذ العينات: مهمة FreeRTOS مخصصة وغير حاجبة تأخذ عينات القنوات الـ 16 تباعاً.
-
تغليف البيانات: بعد مسح كامل، تُغلّف الوحدة قراءات ADC الـ 16 مع طابع زمني (بالميلي ثانية) في حزمة بيانات.
-
تكامل Queue: يجب نشر كل حزمة بيانات إلى queue في FreeRTOS للمعالجة غير المتزامنة.
-
المعاملة بالمعطيات: كل معامل قابل للضبط (فترة أخذ العينات، تأخير تحويل ADC، تأخير استقرار الـ multiplexer، طول الـ queue، تعيينات المنافذ، إلخ) يجب أن يُعرَّف كتوجيه preprocessor.
-
معامل نمط أخذ العينات: يجب أن تدعم الوحدة معاملاً قابلاً للاختيار في وقت الترجمة (MODE_FAST أو MODE_MODERATE أو MODE_HIGHRES) يضبط الإعدادات الزمنية (مثل زمن الاستقرار وتأخير تحويل ADC) بناءً على بيانات الـ datasheet والقياسات العملية.
-
التشغيل غير الحاجب: كل التأخيرات الزمنية يجب أن تُنفَّذ بطريقة غير حاجبة (بتصميم آلة حالات وتسليم التحكم للمجدوِل) دون أي انتظارات حاجبة.
-
معالجة الأخطاء: يجب على الوحدة اكتشاف الأخطاء والإبلاغ عنها (مثل إخفاقات تهيئة العتاد وتجاوزات الـ queue).
المتطلبات غير الوظيفية:
-
الأداء الزمني الحقيقي: يجب أن تعمل مهمة أخذ العينات بأدنى jitter وتُسنَد أولوية كافية.
-
المعيارية وقابلية الصيانة: يجب أن يفصل التصميم بين التحكم في العتاد ومنطق أخذ العينات والتعامل مع RTOS لتسهيل الصيانة.
-
الكفاءة: يجب أن تحافظ الوحدة على دورات CPU بتجنب العمليات الحاجبة.
-
قابلية التوسع: يجب أن يسمح التصميم بتحسينات مستقبلية (مثل ترشيح أو متوسطات إضافية) بأقل تغييرات.
-
قابلية الاختبار: يجب أن تكون وظيفة الوحدة قابلة للتحقق عبر اختبارات الوحدة.
الكود النهائي
فيما يلي كود الوحدة النهائي الكامل:
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