Делаем модульный многоканальный АЦП

в 6:42, , рубрики: avr, diy или сделай сам, программирование микроконтроллеров, сбор данных

В различных проектах часто бывает необходимо следить за множеством параметров, которые представлены аналоговыми величинами. Конечно, часто хватает микроконтроллера, но иногда алгоритм обработки слишком сложен для него и требуется использование полноценного компьютера. К тому же на нём гораздо проще организовывать сохранение логов и красивую визуализацию данных. В таком случае либо берётся готовое промышленное решение (которое, разумеется, стоит дорого, но часто является избыточным), либо делается что-то самодельное. В самом банальном случае это может быть плата 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 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 <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 devicen", 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, "Timeoutn");
		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 secondn", 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: %sn", 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() failedn");
			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 рублей, если заказывать все детали из Китая, так что решение должно быть достаточно привлекательным.

Автор: KivApple

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js