Работа с АЦП. Получаем данные о текущей температуре.
Следующий этап – получение входной информации из окружающего мира. Мы уже научились получать сигналы от пользователя – распознавать нажатия на кнопки и повороты энкодера, теперь будем учиться определять температуру внутри печи!
Пару слов об датчике, который нам в этом поможет – термопаре.
Термопара - устройство из двух металлов, которое при нагревании создает ЭДС (электродвижущую силу) – тут действует эффект Зеебека: если спаи двух разнородных металлов, образующих замкнутую электрическую цепь, имеют неодинаковую температуру, то в цепи протекает электрический ток. Другими словами, термопара – это два сваренных (сплавленных друг с другом) металла, один из которых при нагревании становится «плюсом», другой - «минусом». Таким образом, термопара позволяет определить разность между температурой горячего (измерительного) спая, который находится у необходимого места измерения, и температурой холодного спая, который находится у свободных концов термопары (опытным путём установлено, что этот самый «спай» на самом деле никакой не спай, а просто соединение проводов при замыкании цепи – для измерения того же напряжения; например, если мы воткнём термопару в мультиметр и будем измерять напряжение, то холодный спай будет в месте соединения свободных концов – как бы в самом мультиметре).
Результат измерения будет в вольтах, и его затем нужно будет перевести в удобочитаемый вид.
Рисунок 1. Устройство термопары.
Термопары дешевы и работают с большим диапазоном температур; различают несколько их типов в зависимости от металлов, из которых состоит термопара: например, термопары k-типа – наиболее распространённые в бытовых приборах – состоят из хромеля и алюмеля; термопары J-типа - железо-константановые, E-типа - хромель-константановые и т.д.
Однако есть у термопары и недостатки – во-первых, результат измерения относителен – это разница температур – а во-вторых, в общем случае зависимость между температурой и термоЭДС нелинейна. На рисунке ниже представлен график зависимости ТЭДС и температуры горячего спая (при температуре холодного спая 0 ˚C) для термопары k-типа и линейная аппроксимация этой зависимости; при этом мы взяли температуру горячего спая в границах от 0 до 300 ˚C – именно с такой температурой мы будем работать.
Проведём нехитрые исчисления и заметим, что линейная аппроксимация достаточно точна – погрешность не выходит за рамки среднего изменения ТЭДС на градус.
Рисунок 2.Зависимость ТЭДС и температуры горячего спая для термопары k-типа.
Ещё одна проблема: наш результат получается в мВ – соответственно, при сравнении значения с опорным напряжением 5В у нас получаются какие-то крошечные величины, которые похожи друг на друга; тут было решено действовать «физически» - использовать операционный усилитель AD8541.
Опытным путём было выяснено, что при подключении термопары к операционному усилителю классическими способами возникают несколько проблем:
Первая проблема: операционный усилитель работает не очень-то линейно при напряжениях входа, близких к минусу или, наоборот, плюсу питания – появляется некоторая погрешность, которая зависит как раз от этой близости к минусу или плюсу; при этом усилитель пытается сделать на управляемом входе (входе, к которому подключена обратная связь) такое же напряжение, как на другом входе + некоторая константа.
Вторая проблема – это то, что если мы собираемся использовать усилитель при однополярном питании (V+ - 5 В, V- - земля, 0 В), то на выходе не может получиться отрицательное напряжение, что делает невозможным использование схем с инверсным подключением.
Ну и третья проблема, связанная конкретно с подключением термопары к усилителю: при подключении термопары одним концом к земле (или даже немного «приподняв» над землей один конец), а другим концом к ОУ получается так, что второй конец у нас как бы болтается в воздухе – за счёт того, что входное сопротивление операционного усилителя очень большое. И термопара при этом становится антенной, которая ловит все возможные шумы с окрестности. И именно эти шумы и подаются на вход усилителю, и именно шумы наш усилитель и усиливает – иногда перебивая сам сигнал. Непорядок!
Поэтому мы решили, а почему бы не подать на оба конца термопары землю? И вот что получилось:
Рисунок 3. Схема подключения термопары и операционного усилителя.
При такой схеме на отрицательный вход от термопары поступает небольшое отрицательное напряжение, и ОУ пытается через выход и резистор подать такое же положительное напряжение. При этом первая проблема «превращается» в константное значение, поэтому эта погрешность в последствии уйдет при калибровке. Также нужно заметить, что что на инверсный вход подаётся очень небольшое отрицательное значение – таким образом, мы как бы сглаживаем вторую проблему. Ну и главное преимущество этой схемы – избавление от шумов!
Теперь разберемся с «техникой» - а именно как микроконтроллер получит информацию от термопары. Для этого будем использовать АЦП – аналого-цифровой преобразователь (на вход получаем аналоговый сигнал, а на выход выдаём цифровой). АЦП у нашей микросхемы 10-разрядный, при этом абсолютная погрешность - ±2 младших бита, то есть по сути получаем 8 «чистых» разрядов; максимальное быстродействие – 15000 выборок/с. Однако при такой скорости результат будет не очень точный, да и нам не требуется молниеносно отслеживать изменение входящего сигнала; наибольшая точность АЦП достигается, когда тактовая частота модуля находится в диапазоне 50-200 кГц; при настройке АЦП можно выбрать предделитель (минимум 2, так как АЦП не может работать быстрее, чем ½ частоты процессора, максимум 128) – нас устроит предделитель 128, тогда частота АЦП будет 8000 / 128 = 62.5 кГц, иными словами, преобразование будет производиться раз в 16 мс – этого вполне достаточно.
Входящий сигнал сравнивается с неким опорным: это может быть напряжение питания АЦП, внешний источник опорного напряжения (должен быть не выше напряжения питания) или внутренний источник опорного напряжения (1.1 V, однако многие ругают этот вариант и говорят, что напряжение на внутреннем ИОН «скачет», делая результат АЦП непредсказуемым); по результатам сравнения выдается «значение» уровня сигнала – от минимального, «равного» GND – 0x0000, до максимального, равного опорному напряжению (0x3FFF).
Нужно отметить, что напряжение питания АЦП (AVCC) не должно отличаться от напряжения питания микроконтроллера больше, чем на ±0.3 V. Подробнее про АЦП можно почитать в замечательных книгах Евстифеева Андрея Викторовича.
Попробуем «завести» АЦП: для этого нам нужно его инициализировать (включить и установить предделитель), включить (непрерывное преобразование), сделать функцию прерывания, которая будет записывать значение АЦП в некоторую переменную и отсылать посылку «Пришло новое значение АЦП», а также функцию, которая будет возвращать это самое значение АЦП по запросу: мы не можем этого сделать напрямую, потому что наша переменная будет двухбайтовая, и пока мы читаем первый байт, может вызываться прерывание, которое изменить значение нашей переменной, и второй байт мы прочтём уже от следующего значения АЦП; поэтому сначала придется ждать прихода посылки, потом запрещать прерывания, считывать значение в некоторую промежуточную переменную, разрешать прерывания и, наконец, возвращать значение из промежуточной переменной.
В общем, получаем следующее:
Заголовочный файл ADCTemperature.h:
#ifndef ADC_TEMPERATURE_H #define ADC_TEMPERATURE_H #include "ioavr.h" #include "inavr.h" #include "stdint.h" extern volatile uint8_t OnADCNewValue; //посылка о приходе нового значения АЦП //инициализация АЦП void ADCInit(); //прерывание по получению нового значения АЦП #pragma vector = ADC_vect __interrupt void GetADCValue(void); //включение АЦП void ADCOn(); //выключение АЦП void ADCOff(); //получение нового значения АЦП uint16_t ADCGetNewValue(); #endif
Файл ADC.c:
#include "ADCTemperature.h" volatile uint16_t ADCNewValue; volatile uint8_t OnADCNewValue; //инициализация АЦП void ADCInit() { ADCNewValue = 0; OnADCNewValue = 0; __disable_interrupt(); ADMUX = (1 << REFS0); //источник опорного напряжения AVCC; несимметричный вход ADC0 (MUX4-0 = 0000); выравнивание результата по правому краю (ADLAR = 0) ADCSRB = 0; //режим непрерывного преобразования (ADT2-0 = 000) ADCSRA = (1 << ADEN) | (1 << ADATE) | (1 << ADIE) | //включили АЦП (но не запустили!); разрешили прерывание по получению значения; (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0); //выбрали предделитель частоты 128 __enable_interrupt(); } //включение АЦП void ADCOn() { ADCSRA |= (1 << ADSC) | (1 << ADATE); //запустили АЦП в режиме непрерывного преобразования } //выключение АЦП void ADCOff() { ADCSRA &= (˜((1 << ADSC) | (1 << ADATE))); } //прерывание по получению нового значения АЦП #pragma vector = ADC_vect __interrupt void GetADCValue(void) { uint8_t ADCHigh; ADCNewValue = ADCL; //сохранили результат в "промежуточную" переменную, видную только внутри библиотеки ADCHigh = ADCH; ADCNewValue += (ADCHigh << 8); OnADCNewValue = 1; //отправляем посылку, что есть новое значение } //получение нового значения АЦП uint16_t ADCGetNewValue() { uint16_t Result; while (!OnADCNewValue); //ждём, когда придёт новое значение OnADCNewValue = 0; //очищаем посылку __disable_interrupt(); Result = ADCNewValue; //запоминаем текущее значение АЦП __enable_interrupt(); return Result; }
Видно, что переменные ADCNewValue и OnADCNewValue объявлены volatile – они изменяются не только в основной программе, но и в прерываниях, поэтому мы запрещаем компилятору как-либо оптимизировать код с участием этих переменных – иначе он может посчитать, что они не изменяются, и просто убрать их из конечного кода.
Основной файл, функция main (остальное можно не трогать):
void main( void ) { uint16_t ADCValue; DDRB = 0; PORTB = 0; DDRD = 0; PORTD = 0; DDRC = 0; PORTC = 0; DisplayInit(); ADCInit(); FastTimerInit(); DisplayShowHEX(0); ADCOn(); while(1) { ADCValue = ADCGetNewValue(); DisplayShowHEX(ADCValue); __delay_cycles(4000000); } }
Рассмотрим теперь технические недочёты того, что получилось. Во-первых, самое неприятное, что может случиться при такой библиотеке – то, что мы где-нибудь выключим АЦП (например, в спящем режиме), а затем забудем запустить и попробуем считать значение через функцию ADCGetNewValue – и она, разумеется, подвесит нам всю программу на ожидании посылки OnADCNewValue. Вообще любые подобные циклы while нужно использовать с крайней осторожностью, и если при их использовании программа зависает, то такие участки кода – первые претенденты на проверку.
Кроме того, при использовании виртуальных машин вообще не желательно делать такие while-ы – никто не может гарантировать, что ожидание не затянется; даже 16 мс – это уже много. Поэтому в таком случае лучше заменить ожидание на проверку условия – если посылка пришла, то считываем значение в некоторую переменную и возвращаем «true» - считывание прошло успешно – а иначе возвращаем «false», и все.
Ещё одна проблема – малая разрядность нашего АЦП; два младших байта по сути своей являются шумом, и нам остаётся только 8 разрядов – и при этом мы измеряем температуру от, например, 20 градусов до 300 – это никак не укладывается в 256 значений! Придётся как-то «математически» увеличивать градацию температуры; и несмотря на то, что точность термопары k-типа составляет ±1.5 ˚C, нам нужно постоянно отслеживать именно изменение температуры, поэтому попробуем усреднять и сглаживать получаемое значение.
Подробней о технологии сглаживания и усреднения в этой статье.
Воспользуемся конечными формулами:
(1) Pi+1 = Pi - (Pi / (N * M)) + Xi * M
(2) Ki+1 = (Pi+1 / N) / M
N и M – числа, являющиеся степенями двойки; N – коэффициент инерции фильтра («если сигнал Х в какой-то момент изменил уровень, то каждое N раз измерений значение фильтра будет в e (2,718..) раз ближе к сигналу Х (грубо - на 63%)»)
M же является коэффициентом увеличения разрядности (на сколько разрядов увеличиваем разрядность АЦП).
При этом операции с N и умножение с M мы можем проводить целочисленно, то есть сдвигом, а вот последнее деление на M делаем с плавающей точкой. При этом выполнять расчёт формулы (1) нужно каждый раз, когда мы получаем код АЦП (то есть, раз в 16 мс), а вот получать сглаженное значение – то есть выполнять трудоёмкое деление с плавающей точкой – достаточно по запросу.
Дальше будем переводить коды АЦП в градусы. Мы решили, что будем считать, что перевод линейный – то есть, нам нужны только угловой коэффициент и коэффициент сдвига; далее при необходимости мы просто умножаем сглаженное значение на одну константу и прибавляем другую (естественно, всё это нецелочисленное).
Теперь о большууущем косяке: о компенсации холодного спая. Её мы не реализовали. Дело в том, что когда мы спохватились об этой проблеме, плата была уже разведена, а свободных ножек не осталось. Возможно, когда-нибудь мы всё исправим, но пока будем надеяться, что наша плата не будет особо нагреваться. Путей решения этой проблемы несколько: во-первых, мы можем отказаться от кварца и переписать работу с часовым таймером; на освободившиеся ножки можно навесить какой-нибудь датчик температуры, который будет считывать температуру около платы. Таким датчиком, теоретически, может быть и всем известный ds1820, однако следует помнить, что 1-wire крайне требовательна ко времени (ссылка на статью), и при общении через этот протокол нужно запрещать прерывания – а значит, будут страдать как индикация на дисплее, так и «реакция» контроллера на нажатие на кнопки или вращение энкодера. Помимо ds1820, можно использовать, например, терморезистор (однако у него нелинейная зависимость между сопротивлением и температурой);
Ещё один вариант - хорошее, но дорогое решение – использование специальных усилителей с компенсацией холодного спая (например, AD8495).
Рисунок 4.Схема подключения термопары к операционному усилителю с компенсацией холодного спая.
В общем, на данный момент – пока проект считается учебным - решения с компенсацией холодного спая пока нет, но мы работаем над этим.
Вернемся же к программе – попробуем сделать красивую реализацию считывания, усреднения кодов АЦП и перевод их в температуру.
Внимание! Наша программа становится достаточно большой, поэтому ей нужен больший объём стека! Увеличим его с стандартного "0x20" - 32 байт - до "0х40" - 64 байт, чтоб с запасом было. Настройки меняются в Project->Options.. (смотрите, чтобы в области "Workspace" был выбран не какой-нибудь конкретный файл, а весь проект)->General Options->System; меняем значение Data stack (CSTACK).
Заголовочный файл станет таким:
#ifndef ADC_TEMPERATURE_H #define ADC_TEMPERATURE_H #include "ioavr.h" #include "inavr.h" #include "stdint.h" #define ADC_DEFAULT_ANGULAR_COEF 1 #define ADC_DEFAULT_OFFSET_COEF 0 typedef struct { volatile uint8_t atOnNewADCValue; //посылка о приходе нового значения АЦП //переменные для перевода значения температуры из кода АЦП в градусы (линейная аппроксимация) double atAngularCoef; double atOffsetCoef; volatile uint16_t atADCCurrValue; volatile uint16_t atADCAvgValue; volatile uint32_t atADCAccumulativeSum; double atCurrTemperature; } TADCTemperature; extern TADCTemperature ADCTemperature; //инициализация АЦП void ADCInit(); //прерывание по получению нового значения АЦП #pragma vector = ADC_vect __interrupt void GetADCValue(void); //включение АЦП void ADCOn(); //выключение АЦП void ADCOff(); //получение нового значения температуры (записывается в структуру), возвращает 1 в случае успешного получения нового значения, и 0 - в случае ошибки uint8_t ADCGetNewTemperatureValue(); //получение кода АЦП uint8_t ADCGetNewCode(); #endif
Файл ADCTemperature.c:
#include "ADCTemperature.h" //параметры АЦП-преобразования const uint8_t ADCAvgDegree = 13; //степень двойки у "усреднения" (наше N) const uint8_t ADCExpansionDegree = 2; //степень двойки у "расширения" (из 10-битного АЦП в 12-битное) (наше M) const uint16_t ADCExpansionDivider = 4; //2ˆADCExpansionDegree TADCTemperature ADCTemperature; //инициализация АЦП void ADCInit() { ADCTemperature.atADCAccumulativeSum = 0; ADCTemperature.atADCCurrValue = 0; ADCTemperature.atADCAvgValue = 0; //АЦП инициализируется до определения коэффициентов из EEPROM! ADCTemperature.atAngularCoef = ADC_DEFAULT_ANGULAR_COEF; ADCTemperature.atOffsetCoef = ADC_DEFAULT_OFFSET_COEF; ADCTemperature.atCurrTemperature = 0; ADCTemperature.atOnNewADCValue = 0; __disable_interrupt(); ADMUX = (1 << REFS0); //источник опорного напряжения AVCC; несимметричный вход ADC0 (MUX4-0 = 0000); выравнивание результата по правому краю (ADLAR = 0) ADCSRB = 0; //режим непрерывного преобразования (ADT2-0 = 000) ADCSRA = (1 << ADEN) | (1 << ADATE) | (1 << ADIE) | //включили АЦП (но не запустили!); разрешили прерывание по получению значения; (1 << ADPS2) | (1 << ADPS1) | (1 << ADPS0); //выбрали предделитель частоты 128 __enable_interrupt(); } //включение АЦП void ADCOn() { ADCTemperature.atOnNewADCValue = 0; __disable_interrupt(); ADCSRA |= (1 << ADSC) | (1 << ADATE); //запустили АЦП в режиме непрерывного преобразования __enable_interrupt(); } //выключение АЦП void ADCOff() { __disable_interrupt(); ADCSRA &= (˜((1 << ADSC) | (1 << ADATE))); __enable_interrupt(); } //прерывание по получению нового значения АЦП #pragma vector = ADC_vect __interrupt void GetADCValue(void) { uint8_t ADCHigh; uint32_t currSum; ADCTemperature.atADCCurrValue = ADCL; //сохранили результат ADCHigh = ADCH; ADCTemperature.atADCCurrValue += (ADCHigh << 8); //считаем среднее currSum = (ADCTemperature.atADCAccumulativeSum >> (ADCExpansionDegree + ADCAvgDegree)); currSum += ADCTemperature.atADCAccumulativeSum; ADCTemperature.atADCAccumulativeSum = (ADCTemperature.atADCCurrValue << ADCExpansionDegree) - currSum; ADCTemperature.atOnNewADCValue = 1; //отправляем посылку, что есть новое значение АЦП } //получение нового значения температуры (записывается в структуру), возвращает 1 в случае успешного получения нового значения, и 0 - в случае ошибки uint8_t ADCGetNewTemperatureValue() { if (ADCTemperature.atOnNewADCValue) //если пришло новое значение АЦП { ADCTemperature.atOnNewADCValue = 0; __disable_interrupt(); ADCTemperature.atADCAvgValue = ADCTemperature.atADCAccumulativeSum >> ADCAvgDegree; //усредняем по формуле значение АЦП __enable_interrupt(); //увеличиваем разрядность АЦП ADCTemperature.atCurrTemperature = (double)ADCTemperature.atADCAvgValue / ADCExpansionDivider; //вычисляем значение температуры по линейной формуле ADCTemperature.atCurrTemperature = ADCTemperature.atAngularCoef * ADCTemperature.atTemperatureValue + ADCTemperature.atOffsetCoef; return 1; } else return 0; } //получение нового значения температуры (записывается в структуру), возвращает 1 в случае успешного получения нового значения, и 0 - в случае ошибки uint8_t ADCGetNewCode() { if (ADCTemperature.atOnNewADCValue) //если пришло новое значение АЦП { ADCTemperature.atOnNewADCValue = 0; __disable_interrupt(); ADCTemperature.atADCAvgValue = ADCTemperature.atADCCurrValue; //считываем реальное значение АЦП //ADCTemperature.atADCAvgValue = ADCTemperature.atADCAccumulativeSum >> ADCAvgDegree; //считываем усредненное значение АЦП __enable_interrupt(); return 1; } else return 0; }
Добавляем в библиотеку семисегментного дисплея функцию отображения температуры:
//перевод числа с плавающей точкой в строку; точность - десятые доли; [-999.9; 9999.9]; занимает 6 символов, в конец '\0' не пишет void DoubleThousandsWithTenthToStr(double Value, char* buff) { uint16_t intValue, definitionNumbers; if (Value < 0) //на первый сегмент пишем "-", если число отрицательно { buff[0] = '-'; intValue = (uint16_t)(Value * (-1)); Value = Value * (-1); } else //или сотни положительного числа, или просто " " { intValue = (uint16_t)Value; if (intValue >= 1000) { definitionNumbers = (intValue / 1000) % 10; buff[0] = definitionNumbers + '0'; } else buff[0] = DispBlankSymbol; } if (intValue >= 100) { definitionNumbers = (intValue / 100) % 10; buff[1] = definitionNumbers + '0'; } else buff[1] = DispBlankSymbol; //пишем десятки, если они есть, или пробел if (intValue >= 10) { definitionNumbers = (intValue / 10) % 10; buff[2] = definitionNumbers + '0'; } else buff[2] = DispBlankSymbol; buff[3] = intValue % 10 + '0'; //записали единицы buff[4] = '.'; buff[5] = ((uint16_t)(Value * 10)) % 10 + '0'; //записали десятичные } //перевод числа с плавающей точкой в строку; точность - десятые доли; [-99.9; 999.9]; занимает 5 символов, в конец '\0' не пишет void DoubleHundredsWithTenthToStr(double Value, char* buff) { uint16_t intValue, definitionNumbers; if (Value < 0) //на первый сегмент пишем "-", если число отрицательно { buff[0] = '-'; intValue = (uint16_t)(Value * (-1)); Value = Value * (-1); } else //или сотни положительного числа, или просто " " { intValue = (uint16_t)Value; if (intValue >= 100) { definitionNumbers = (intValue / 100) % 10; buff[0] = definitionNumbers + '0'; } else buff[0] = DispBlankSymbol; } //пишем десятки, если они есть, или пробел if (intValue >= 10) { definitionNumbers = (intValue / 10) % 10; buff[1] = definitionNumbers + '0'; } else buff[1] = DispBlankSymbol; buff[2] = intValue % 10 + '0'; //записали единицы buff[3] = '.'; buff[4] = ((uint16_t)(Value * 10)) % 10 + '0'; //записали десятичные } //отображение значения температуры на дисплее void DisplayShowTemperatureValue(double Value) { char buff[9]; DoubleThousandsWithTenthToStr(Value, buff); //перевели число //buff[5] = DispBlankSymbol; buff[6] = 'C'; buff[7] = DispPointSymbol; buff[8] = 0; DisplayShowStr(buff); }
Пока - до калибровки - будем отображать температуру начиная с тысяч; потом уже, когда научимся калибровать значение, будем отображать температуру до сотен.
Основной файл, функция main:
void main( void ) { DDRB = 0; PORTB = 0; DDRD = 0; PORTD = 0; DDRC = 0; PORTC = 0; DisplayInit(); ADCInit(); FastTimerInit(); DisplayShowHEX(0); ADCOn(); while(1) { if (ADCGetNewTemperatureValue()) //если есть новое значение АЦП { DisplayShowTemperatureValue(ADCTemperature.atCurrTemperature); } __delay_cycles(8000000); } }
Автор - Moriam =ˆˆ=