[Из песочницы] Делаем модульный многоканальный АЦП

В различных проектах часто бывает необходимо следить за множеством параметров, которые представлены аналоговыми величинами. Конечно, часто хватает микроконтроллера, но иногда алгоритм обработки слишком сложен для него и требуется использование полноценного компьютера. К тому же на нём гораздо проще организовывать сохранение логов и красивую визуализацию данных. В таком случае либо берётся готовое промышленное решение (которое, разумеется, стоит дорого, но часто является избыточным), либо делается что-то самодельное. В самом банальном случае это может быть плата Arduino с бесконечным циклом из analogRead и serial.write. Если входных данных много (больше, чем аналоговых входов), то потребуется несколько плат, придумывать как их правильно опрашивать с компьютера и т. д. Во многих случаях подойдёт разработанное мною решение (возможно, я не первый придумал именно такую реализацию, не особо интересовался этим вопросом), которое позволит сэкономить время на отладку и сделать относительную простую и понятную архитектуру системы.584c9448558f4851a09bb71c81f8ee42.jpgЧтобы понять, подойдёт ли это решение вам, предлагаю ознакомится с его характеристиками:

Максимальное число каналов: 44; Частота дискретизации: 1000 Герц; Разрешение: 8 бит.

Характеристики достаточно посредственные, однако для многих задач могут подойти. Это ведь не осциллограф, а система опроса датчиков. К тому же на её примере можно познакомится с использованием USART не совсем по назначению.

Система состоит из отдельных модулей АЦП на базе микроконтроллера ATMEGA8 (можно применить другой МК семейства AVR с АЦП и аппаратным модулем USART, если немного изменить прошивку). Модулей может быть один или несколько, каждый предоставляет 6 или 8 АЦП в зависимости от корпуса микроконтроллера (выводная версия имеет 6 АЦП, а для поверхностного монтажа 8), только суммарное количество каналов не должно превышать 44. Главная особенность в том, что вне зависимости от количества модулей требуется лишь один USART со стороны компьютера (это может быть USB-переходник или аппаратный COM-порт). Это достигается засчёт того, что USART’ы всех микроконтроллеров соединяются последовательно (RX одного к TX другого), а RX и TX пины крайних в цепочке уже подсоединяются к компьютеру.

Тут надо заметить то, что разрядность моего АЦП не совсем 8 бит — возможно лишь 255 градаций вместо 256. Значение 0xFF зарезервировано для особой цели. Если микроконтроллер получает его, то начинает выдавать каждый раз значение с очередного канала своего АЦП, а когда они кончаются ретранслирует 0xFF дальше по цепочке. Если же на вход USART приходит значение отличное от 0xFF, то микросхема просто пересылает байт далее. Таким образом передав одно произвольное значение и 44 0xFF можно получить значения со всех каналов всех АЦП (если АЦП меньше, то лишние каналы будут равны 0xFF). Произвольное значение нужно для того, чтобы все модули сбросили указатель на текущий канал АЦП, который надо передавать при получении 0xFF. В реальности удобнее передавать 45 0xFF, чтобы надёжно определять окончание приёма (если получили 0xFF, значит каналы закончились).

Принципиальная схема содержит не очень много деталей: 54466ee8a4ef4e3fa5c1a279d8a912c4.png

Программа для AVR выглядит предельно просто и занимает чуть меньше 300 байт памяти:

#include #include #include #include // Firmware options #define USART_BAUDRATE 460800 #define LED_PIN 1 #define ADC_COUNT 6 #define STARTUP_DELAY 1000 // Calculated UBRR value #define UBRR (F_CPU / (16 * (uint32_t)USART_BAUDRATE) — 1) // Global variables uint8_t adc[ADC_COUNT]; // Buffer uint8_t cur_in_adc; // Input byte index uint8_t cur_out_adc; // Output byte index // USART interrupt handler ISR (USART_RXC_vect) { // Read data from USART uint8_t buffer = UDR; if (buffer == 0xFF) { if (cur_out_adc < ADC_COUNT) { // Return data byte from buffer UDR = adc[cur_out_adc]; cur_out_adc++; // Activate led PORTB |= _BV(LED_PIN); } else { // Chain 0xFF UDR = 0xFF; // Deactivate led PORTB &= ~_BV(LED_PIN); } } else { // Chain data byte UDR = buffer; // Reset byte counter cur_out_adc = 0; // Deactivate led PORTB &= ~_BV(LED_PIN); } } // Main function void main() { // Setup watchdog timer wdt_enable(WDTO_15MS); // Setup pin for led DDRB |= _BV(LED_PIN); // Blink led PORTB |= _BV(LED_PIN); for (uint8_t i = 0; i < STARTUP_DELAY / 5; i++) { _delay_ms(5); wdt_reset(); } PORTB &= ~_BV(LED_PIN); // Setup ADC ADMUX = _BV(REFS1) | _BV(REFS0) | _BV(ADLAR); ADCSRA = _BV(ADEN) | _BV(ADPS2) | _BV(ADPS1) | _BV(ADPS0); // Setup USART UBRRL = UBRR & 0xFF; UBRRH = UBRR >> 8; UCSRA = 0; UCSRB = _BV (RXCIE) | _BV (RXEN) | _BV (TXEN); UCSRC = _BV (URSEL) | _BV (UCSZ1) | _BV (UCSZ0); // Enable interrupts sei (); // Main loop while (1) { // Reset watchdog timer wdt_reset (); // Select ADC channel ADMUX = _BV (REFS1) | _BV (REFS0) | _BV (ADLAR) | cur_in_adc; // Start conversion and wait until it performed ADCSRA |= _BV (ADIF) | _BV (ADSC); while (ADCSRA & _BV (ADSC)); // Put value from ADC to buffer uint8_t value = ADCH; adc[cur_in_adc] = (value!= 0xFF) ? value: 0xFE; // Switch to next channel cur_in_adc++; if (cur_in_adc >= ADC_COUNT) { cur_in_adc = 0; } } } А вот простой пример программы для компьютера. Она принимает в качестве параметра имя последовательного порта и начинает выдавать на stdout данные в формате CSV, а на stderr статистику (байт передано, получено и сколько замеров произведено за секунду). Можно просто перенаправить её вывод в файл, а потом открыть его в Excel, Calc или более подходящей программе, а можно легко использовать её в качестве бэкэнда в своём приложении, перехватив её вывод. Приложение изначально написано под Linux, но в теории может быть собрано с использованием Cygwin для ОС семейства Windows.

#include #include #include #include #include #include #include #include #include #include #include #include #include // Settings #define DEFAULT_BAUDRATE 460800 #define MAX_CHANNEL_COUNT 44 #define READ_BUFFER_SIZE 256 // Global variables volatile sig_atomic_t write_buffer_offset = INT_MAX; volatile sig_atomic_t remaining_writes = 0; volatile sig_atomic_t write_counter = 0; volatile sig_atomic_t read_counter = 0; volatile sig_atomic_t sample_counter = 0; int adc_data[MAX_CHANNEL_COUNT]; int adc_count = 0; int cur_adc = -1; uint8_t command[(MAX_CHANNEL_COUNT + 2) * 1000]; // Print usage information void print_usage (char *program_name) { fprintf (stderr, «Usage: %s device\n», program_name); fprintf (stderr,»\tdevice — path to serial device (e.g. /dev/ttyS0 or /dev/ttyUSB0)\n»); fprintf (stderr,»\n»); } // Open serial port int open_serial_device (char *path) { // Open device int fd = open (path, O_RDWR | O_NONBLOCK); if (fd == -1) return -1; // Get current options struct termios options; tcgetattr (fd, &options); // Set baudrate cfsetspeed (&options, DEFAULT_BAUDRATE); // Set mode (8N1) options.c_cflag &= ~PARENB; options.c_cflag &= ~CSTOPB; options.c_cflag &= ~CSIZE; options.c_cflag |= CS8; // Disable hardware flow control (if available) #ifdef CNEW_RTSCTS options.c_cflag &= ~CNEW_RTSCTS; #elifdef CRTSCTS options.c_cflag &= ~CRTSCTS; #endif // Set new options tcsetattr (fd, TCSANOW, &options); // Return handle return fd; } // Alarm handler void alarm_handler (int sig) { // Check for timeout static int first_run = 1; if (first_run) { first_run = 0; } else if (! sample_counter) { fprintf (stderr, «Timeout\n»); exit (-2); } // Send next command if (write_buffer_offset >= sizeof (command)) { write_buffer_offset = 0; } else { remaining_writes++; } // Display debug info fprintf (stderr, «Writing %i bps, reading %i bps, %i samples per second\n», write_counter, read_counter, sample_counter); // Reset performance counter read_counter = 0; write_counter = 0; sample_counter = 0; } // Print ADC data void print_adc_data () { int i; for (i = 0; i < adc_count; i++) { if (i) { printf(",%i", adc_data[i]); } else { printf("%i", adc_data[i]); } } printf("\n"); } // Main function int main(int argc, char **argv) { if (argc < 2) { print_usage(argv[0]); } else { // Open serial port char *device = argv[1]; int device_fd = open_serial_device(device); if (device_fd == -1) { fprintf(stderr, "Failed to open %s: %s\n", device, strerror(errno)); return -1; } // Setup alarm signal handler { struct sigaction sig; sig.sa_handler = alarm_handler; sigemptyset(&sig.sa_mask); sig.sa_flags = SA_RESTART; sigaction(SIGALRM, &sig, NULL); } // Setup timer timer_t timer; if (timer_create(CLOCK_MONOTONIC, NULL, &timer)) { perror("timer_create() failed\n"); close(device_fd); return -1; } { struct itimerspec timer_spec; timer_spec.it_interval.tv_sec = 1; timer_spec.it_interval.tv_nsec = 0; timer_spec.it_value.tv_sec = 0; timer_spec.it_value.tv_nsec = 1; if (timer_settime(timer, 0, &timer_spec, NULL) < 0) { perror("timer_settime() failed"); close(device_fd); return -1; } } // Generate USART command { int i; memset(command, 0xFF, sizeof(command)); for (i = 0; i < sizeof(command); i += MAX_CHANNEL_COUNT + 2) { command[i] = 0; } } // Main loop while (1) { // Wait device ready for reading or writing fd_set fds_r, fds_w; FD_ZERO(&fds_r); FD_ZERO(&fds_w); FD_SET(device_fd, &fds_r); FD_SET(device_fd, &fds_w); int retval = select(FD_SETSIZE, &fds_r, &fds_w, NULL, NULL); // Check for errors if (retval < 0) { if (errno == EINTR) continue; perror("select() failed"); timer_delete(timer); close(device_fd); return -1; } // Read data if (FD_ISSET(device_fd, &fds_r)) { uint8_t buffer[READ_BUFFER_SIZE]; int bytes_count; while ((bytes_count = read(device_fd, buffer, sizeof(buffer))) > 0) { read_counter += bytes_count; int i; for (i = 0; i < bytes_count; i++) { if (buffer[i] == 0xFF) { if (adc_count) { print_adc_data(); sample_counter++; adc_count = 0; } cur_adc = -1; } else { if ((cur_adc > -1) && (cur_adc < MAX_CHANNEL_COUNT)) { if (buffer[i] != 0xFF) { adc_data[cur_adc] = buffer[i]; adc_count++; } } cur_adc++; } } } } // Write data if (FD_ISSET(device_fd, &fds_w) && (write_buffer_offset < sizeof(command))) { int bytes_count; while ((bytes_count = write(device_fd, command + write_buffer_offset, sizeof(command) - write_buffer_offset)) > 0) { write_counter += bytes_count; write_buffer_offset += bytes_count; } if ((write_buffer_offset >= sizeof (command)) && remaining_writes) { write_buffer_offset = 0; remaining_writes--; } } } // Cleanup timer_delete (timer); close (device_fd); } return 0; } Удобно, что последовательный порт используется практически на пределе своих возможностей, поэтому не требуется заботится о синхронизации данных (я просто отправляю каждую секунду сразу 1000 команд на чтение АЦП) — если мы передаём 46 килобайт данных каждую секунду со скоростью 460800 бит в секунду, то можно быть полностью уверенным, что блоки из 46 байт данных (один замер) будут приходить каждую миллисекунду (хотя буферизация ядром ОС и USB-переходником, конечно, внесёт задержку, но замеры всегда будут производится с нужной частотой).

Печатная плата была спроектирована в KiCad:

c7d916409e4f446b875fed69b4619e3a.png

Все платы соединяются в цепочку, у последней платы RX и TX соединяются джампером.Качество работы АЦП можно оценить по этому изображению пилы на 10 Гц:

c69d2420a39c40138839f8a6417db5a0.png

Для сравнения изображение с осциллографа DS203 (он же и выступает генератором):

b7018fd4f2a448e4b4bf78e2e4045394.png

К сожалению, у меня нет более качественного источника сигнала, но для низкочастотных сигналов моя система должна подойти.

Надо отметить, что не каждый преобразователь USART-USB обеспечивает скорость 460800 бит/сек при полной загрузке канала. Преобразователь на базе CP2102 заставил меня долго искать ошибку в собственном коде, пока я не попробовал FT232. Также наблюдается потеря порядка 0.17% данных (в программе для компьютера приняты меры, чтобы не терялась синхронизация данных). Скорее всего это вызвано плохой линией USART, либо недоработкой в программе. В общем, для 90% применений не должно быть критично, но ставить на АЭС скорее всего не стоит.

Ну и напоследок скажу, что себестоимость одного модуля получается около 50–60 рублей, если заказывать все детали из Китая, так что решение должно быть достаточно привлекательным.

© Habrahabr.ru