Работа с АЦП. Получаем данные о текущей температуре.

Следующий этап – получение входной информации из окружающего мира. Мы уже научились получать сигналы от пользователя – распознавать нажатия на кнопки и повороты энкодера, теперь будем учиться определять температуру внутри печи!
Пару слов об датчике, который нам в этом поможет – термопаре.
Термопара - устройство из двух металлов, которое при нагревании создает ЭДС (электродвижущую силу) – тут действует эффект Зеебека: если спаи двух разнородных металлов, образующих замкнутую электрическую цепь, имеют неодинаковую температуру, то в цепи протекает электрический ток. Другими словами, термопара – это два сваренных (сплавленных друг с другом) металла, один из которых при нагревании становится «плюсом», другой - «минусом».  Таким образом, термопара позволяет определить разность между температурой горячего (измерительного) спая, который находится у необходимого места измерения, и температурой холодного спая, который находится у свободных концов термопары (опытным путём установлено, что этот самый «спай» на самом деле никакой не спай, а просто соединение проводов при замыкании цепи – для измерения того же напряжения; например, если мы воткнём термопару в мультиметр и будем измерять напряжение, то холодный спай будет в месте соединения свободных концов – как бы в самом мультиметре).
Результат измерения будет в вольтах, и его затем нужно будет перевести в удобочитаемый вид.


Рисунок 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  =ˆˆ=

Обсудить на форуме