Робимо модульний багатоканальний АЦП

У різних проектах часто буває необхідно стежити за безліччю параметрів, які представлені аналоговими величинами. Звичайно, часто вистачає мікроконтролера, але іноді алгоритм обробки занадто складний для нього і потрібне використання повноцінного комп'ютера. До того ж на ньому набагато простіше організовувати зберігання логів і гарну візуалізацію даних. У такому випадку або береться готове промислове рішення (яке, зрозуміло, коштує дорого, але часто є надлишковим), або робиться щось саморобне. У самому банальному випадку це може бути плата Arduino з нескінченним циклом з analogRead і serial.write. Якщо вхідних даних багато (більше, ніж аналогових входів), то буде потрібно кілька плат, придумувати як їх правильно опитувати з комп'ютера і т. д. У багатьох випадках підійде розроблене мною рішення (можливо, я не перший придумав саме таку реалізацію, не особливо цікавився цим питанням), яка дозволить заощадити час на відлагодження і зробити відносну просту і зрозумілу архітектуру системи.



Щоб зрозуміти, чи підійде це рішення вам, пропоную ознайомитися з його характеристиками:

Максимальне число каналів: 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, значить канали закінчилися).

Програма для AVR виглядає гранично просто і займає трохи менше 300 байт пам'яті:

#include <avr/io.h>
#include <avr/interrupt.h>
#include <avr/wdt.h>
#include <util/delay.h>
// 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 buffer to
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 < stdio.h> 
#include < stdlib.h>
#include <stdint.h>
#include < string.h>
#include < errno.h>
#include < unistd.h>
#include < fcntl.h>
#include <termios.h>
#include < time.h>
#include <signal.h>
#include <limits.h>
#include < sys/types.h>
#include < sys/select.h>
// 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);
// Handle Return
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:



Всі плати з'єднуються в ланцюжок, у останньої плати RX і TX з'єднуються джампером.
Якість роботи АЦП можна оцінити по цьому зображенню пили на 10 Гц:



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



На жаль, у мене немає більш якісного джерела сигналу, але для низькочастотних сигналів моя система повинна підійти.

Треба зазначити, що не кожен перетворювач USART-USB забезпечує швидкість 460800 біт/сек при повному завантаженні каналу. Перетворювач на базі CP2102 змусив мене довго шукати помилку у власному коді, поки я не спробував FT232. Також спостерігається втрата порядку 0.17% даних (у програмі для комп'ютера вжиті заходи, щоб не втрачалася синхронізація даних). Швидше за все це викликано поганою лінією USART, або недоробкою в програмі. Загалом, для 90% застосувань не повинно бути критично, але ставити на АЕС швидше за все не варто.

Ну і наостанок скажу, що собівартість одного модуля виходить близько 50-60 рублів, якщо замовляти всі деталі з Китаю, так що рішення має бути досить привабливим.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.