вівторок, 31 липня 2012 р.

Робота з EEPROM пам'яттю 24CXX -- Soft, AVR8

Відповідність пінів Ардуїно Mega2560 пінам
його контролера, ATmega2560.
  Клікабельно -- без того нічого не видно.
Взято з офіційного сайта, там же
таблиця відповідності.
В попередній нотатці працювалося із шиною I2C чисто програмно, але засобами Wiring -- бібліотек Ардуїно. Робити так не дуже елегантно і не дуже ефективно, хоча і просто.

На цей раз працюватимемо з I2C програмно, використовуючи безпосередньо засоби контролера (і трішки avr-libc, стандартної бібліотеки компілятора AVR-GCC).

Налаштування середовища


Під час написання цієї нотатки використовувалося офіційне середовище від Atmel, виробника -- Atmel Studio 6 (ще недавно -- AVR Studio). Версія 6 якраз нормально підтримує C++, що приємно (*1). Безкоштовна. Компілятор -- gcc (avr-gcc).  Скачати її можна тут. Вимагає (простої) реєстрації. Забігаючи наперед, скажу, що завантаження додатків (з-під самої студії) вимагає окремої, хоча теж тривіальної, реєстрації. Як подружити Ардуїно та AVR Studio 6 описано тут: "Atmel Studio 6 with Arduino Boards - Part 1".

Якщо коротко, студію інсталюємо як звичайно. На жаль, вона не вміє працювати із програматором Ардуїно, тому в ролі зовнішнього програматора під'єднується avrdude, що йде разом із оболонкою Ардуіно. Важливо, avrdude з актуального зараз Arduino IDE, 1.0.1, не працюватиме. Слід взяти трішки старіше, наприклад з  Arduino IDE 023. Як це зробити, які параметри передавати avrdude, описано в згаданій статті, а також на їх форумі. Робота відбувається просто -- компілюється проект в Студії (для проекту вибираємо правильний контролер, в нашому випадку Mega2560!), далі прошиваємо контролер за допомогою avrdude (якщо програму сконфігуровано так, як описано у вказаних статтях, в момент виклику активним має бути вкладка із головним файлом програми, тим що містить int main()).

Також, можна заінсталювати Terminal Manager, аналог Ардуїнівського серіального монітора. (Кожна з цих програм має свої плюси і мінуси, хоча ардуїнівський мені видається приємнішим. Принаймні, 32 кб виводу він перетравлює спокійно, а Terminal Manager починає безбожно підвисати.)

"Atmel Studio 6 with Arduino Boards - Part 1" має продовження, в наступних частинах, 2 і 3, автор розповідає, як  підключити стандартні бібліотеки Ардуїно та зовнішні бібліотеки. Якщо цікаво, є і документація, як подружити Ардуїно та попередню версію, AVR Studio 5.


Кілька слів про архітектуру


Розповідати про будову архітектури AVR-8 в деталях не буду, по перше сам толком не знаю, по друге -- довго. :-) Тому лише кілька слів. Яких замало щоб розібратися, але тим, хто має досвід роботи з Arduino і мінімально  орієнтується в принципі дії контролерів, сприйняття спростять.

"Робочі" виводи контролера, "піни", "належать" до портів вводу-виводу. Кожному порту відповідає набір регістрів, які керують його станом. Наприклад, ATmega2560 має порти A, B, C, E, F, G, H, J, K, L -- восьмибітні (об'єднують по 8 "ніжок"-виводів), G - шестибітний. Кожен із портів може мати якісь унікальні характеристики, аналогічно -- його окремі виводи. Наприклад, піни, з якими працює TWI (для ардуїно Mega -- з номерами 20, 21, див. тут), належать порту D, з номерами 0 (SCL) та 1 (SDA). Детальніше -- див. даташіти. По замовчуванню, кожним виводом потру можна керувати окремо -- переключати його в режим цифрового вводу чи виводу, читати, що на нього подано ззовні, виводити вказане значення, переключати на спеціальні функції. Про переключення на спеціальні функції пізніше, коли розглядатимемо роботу з апаратурою TWI. Поки що розглянемо їх роботу в звичайному цифровому режимі.

Кожному порту відповідає три 8-бітних регістри (для G задіяно лише 6 молодших бітів!): PORTA, DDRA, PINA; PORTB, DDRB, PINB, і т.д.,  якщо коротко: PORTx, DDRx, , PINx. (Data Register, Data Direction Register, і Port Input, відповідно).

DDRx керує режимом роботи виводів. Якщо в якийсь біт записано 0,  відповідний вивід контролера працює як вхід -- можна читати значення, якщо 1 -- на вивід, може генерувати логічний нуль чи одиничку.

Якщо вивід працює на вхід (біт в DDRx = 0), його біт в PORTx задає, чи використовувати внутрішній резистор зсуву до позитивної напруги (pull-up). Один -- використовувати, 0 - ні. Якщо біт в DDRx =1, ніжка працює на вивід, то те, що записується в PORTx і буде на неї виводитися. Записали 0 - на ній нуль, записали 1 - на ній логічна одиничка, +5В (+3.3В для деяких схем :).

DDRx та PORTx можна читати, однак прочитається з них тільки те, що туди було записано. Щоб в режимі вводу прочитати, що ж є на ніжці, використовується регістр PINx. Запис у відповідний його біт одинички переключає значення в PORTx на протилежне.

Якщо програмувати на С/С++, до цих регістрів можна звертатися як до змінних (хоча і завжди пам'ятати, що це таки регістри процесора!) (*2). Наприклад, щоб переключити біт 5 порту A на вивід і вивести 0, можна зробити так:

  DDRA |= 0x20; 
  PORTA &= ~(0x20);

20 шістнадцяткове = 100000 двійкове, тобто відмінний від нуля лише 5-й (нумеруючи від нуля і зліва направо) біт. Після операції DDRA |= 0x20, побітного АБО, він стане рівним 1. Наступна команда встановить 5-й біт в PORTA рівним нулю. (Побітове І, з оберненим до 00100000 b, тобто з 11011111b якраз дає такий результат).

Щоб не вгадувати кожен раз, яке значення відповідає даному біту, можна робити так:

  1 << 5

Тобто, зсунути одиничний біт на 5 позицій ліворуч, автоматично отримавши потрібне значення:

  DDRA |= 1 << 5; 

Можна також  скористатися простим макросом із AVR Libc, _BV(bit) (з файлу заголовків <avr/sfr_defs.h>):

  DDRA |= _BV(5);

Arduino кожному такому виводу ставить у відповідність свій пін. DigitalWrite/DigitalRead транслює переданий номер у відповідний порт та номер виводу в ньому.  Таблиця відповідності для Arduino Mega2560 -- тут, а також на картинці на початку поста.

Регістри вводу-виводу (як і регістри загального використання) контролерів архітектури AVR-8 відображаються в пам'ять, тому до них можна звертатися і за адресами в пам'яті. Щоб не вгадувати адреси кожного разу (вони ще й різні для різних моделей бувають), можна використати макрос _SFR_IO8.

AVR Libc багато різних корисних функцій, поміж них -- функції затримки, _delay_ms i _delay_us. Перша задає затримку в мілісекундах, друга -- в мікросекундах. Означені вони в <util/delay.h>. Щоб вони функціонували коректно, перед включенням <util/delay.h> слід оголосити змінну препроцесора F_CPU, з частотою контролера в герцах:

    #define F_CPU 16000000UL  // 16 MHz
    #include <util/delay.h>

Ще одне важливе зауваження. Більшість команд контролер виконує за 1 такт. Компілятор досить ефективний, тому команди високого рівня зазвичай транслюються у доволі прості послідовності команд процесора. Наприклад :

 DDRA |= 1 << 5; 

дасть на виході щось типу

cbi    0x02, 0x20

і виконуватиметься 1 такт. (Так, я свідомо вибрав максимально просту для компіляції команду. :) На частоті 16МГц, 1 такт це 1/16 мікросекунди, 62.5 наносекунди. Далеко не всі перехідні процеси в електричних колах встигають завершитися, тому іноді потрібно робити затримки, не говорячи про те, що периферійні пристрої можуть бути значно повільнішими за контролер. Про це трішки далі. Крім того, коли керувати шиною I2C програмно, частота на SCL "гулятиме", через те, що різні підпрограми містять трішки різну кількість команд. Для I2C -- байдуже, але іноді точний тайминг важливий периферії, доводиться бути дуже акуратним, або взагалі писати на асемблері. Зате час виконання команд AVR-8 значно більш передбачуваний, ніж у x86.

Так як Ардуїнівський Serial нам тепер недоступний (*3), щоб бачити результат роботи програми, реалізовано прості засоби роботи із USART. Вони вміють виводити літери, С-стрічки та цілі числа. Детально їх функціонування не розглядатиму, кому цікаво -- див. вихідні тексти програми.


Програмуємо I2C

 
Для простоти залишимо апаратну схему тією ж, що і для попередніх нотаток. Пін WC  -- 23 пін Ардуїно, відповідатиме біту 1 порта A (якщо коротко -- PORTA1). Піни 20 і 21 (альтернативною функцією яких є TWI, але ми цією машинерією поки не користуватимемося!) належать порту D, біти з номерами 0 і 1.

Програма містить багато перевірок стану шини. Звичайно, викинувши їх, можна дещо збільшити максимальну доступну швидкість, але відлагоджувати програму стане значно складніше.

Ініціалізуємо перераховані виводи:

// Пін, до якого під'єднано WC
const int write_enable=1; // Pin 23 -- PortA1;

const int SDA_pin=1; // Pin 20 -- PortD1
const int SCL_pin=0; // Pin 21 -- PortD0 
 
 
    // Біт write_enable порта A (пін 23 ардуїно) -- на вивід, 
    // Встановлюємо у його регістрі керування відповідний біт в 1
    DDRA |= _BV(write_enable);
    // подати 0, дозволивши запис в мікросхему
    // Зануляємо відповідний біт порта A
    PORTA &= ~(_BV(write_enable));
    
    //------------------------------------------------------------
    // Встановлюємо для пінів, які функціонуватимуть софтварними SCL i SDA 
    // намертво вивід 0 -- щоб не було короткого замикання
    PORTD &= ~(_BV(SCL_pin));
    PORTD &= ~(_BV(SDA_pin));

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

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

//-Записуємо байт--------------------------------------------------
    int res;
    uint8_t address_write = device_id << 1;
    // Біт R/W автоматично, після зсуву - нуль, запис.
    // Посилаємо сигнал Start
    res=send_start();
    // Виводимо res в серіальний порт
    
    // Посилаємо апаратну адресу
    res=send_byte(address_write);
    // Виводимо res в серіальний порт

    // Посилаємо старший байт адреси
    res=send_byte(byte_address >> 8);
    // Виводимо res в серіальний порт

    // Посилаємо молодший байт адреси
    res=send_byte(byte_address & 0xFF);
    // Виводимо res в серіальний порт
  
    // Посилаємо байт для запису
    res=send_byte(byte_value);
    // Виводимо res в серіальний порт
  
    // Посилаємо сигнал Stop
    res=send_stop();
    // Виводимо res в серіальний порт

//-Читаємо байт--------------------------------------------------
    
    // Посилаємо сигнал Start
    res=send_start();
    // Виводимо res в серіальний порт
    
    // Посилаємо апаратну адресу
    res=send_byte(address_write);
    // Виводимо res в серіальний порт
    // Посилаємо старший байт адреси
    res=send_byte(byte_address >> 8);
    // Виводимо res в серіальний порт

    // Посилаємо молодший байт адреси
    res=send_byte(byte_address & 0xFF);
    // Виводимо res в серіальний порт
    
    // Адресу передали, читаємо:
    
    uint8_t address_read = device_id << 1;
    // Встановлюємо молодший біт, R/W -- читання
    address_read |= 0x01;
    
    res=send_start();
    // Виводимо res в серіальний порт
    
    // Посилаємо апаратну адресу
    res=send_byte(address_read);
    // Виводимо res в серіальний порт
    
    // Отримуємо байт, посилаємо NACK -- досить
    res=receive_byte(&byte_readed, (int)false);
    // Виводимо res в серіальний порт
    // Виводимо прочитане
    
    // Посилаємо сигнал Stop
    res=send_stop();
    // Виводимо res в серіальний порт

Посилання Start виглядає так:

// Посилає сигнал Start
// Якщо все ОК, повертає 0
// Якщо лінія не піднялася для SDA -- повертає 1
// Якщо лінія не піднялася для SCL -- повертає 2
int send_start()
{
    // Порт, до якого під'єднано наші SCL i SDA - D
    // На вивід SDA
    DDRD &= ~(_BV(SDA_pin)); // Лінію притягне до 1
    stabilization_delay();
    //Перевіримо, чи притягнуло:
    if( not (PIND & _BV(SDA_pin)) )
        return 1; // Лінію SDA насильно притиснуто до нуля

    // На вивід SCL
    DDRD &= ~(_BV(SCL_pin)); // Лінію притягне до 1
    stabilization_delay();
    //Перевіримо, чи притягнуло:
    if( not (PIND & _BV(SCL_pin)) )
        return 2; // Лінію SCL насильно притиснуто до нуля
    
    // Шина наша, починаємо працювати. (Вважаємо, що інших Master
    // на шині немає, для простоти не реалізовуємо арбітраж)
    
    // Для опускання лінії переключаємо на вивід, а виводимо, 
    // що зафіксовано на початку програми, 0
    DDRD |= _BV(SDA_pin); // Опускаємо лінію SDA
    _delay_us(def_delay);
    DDRD |= _BV(SCL_pin); // Опускаємо лінію SCL
    
    //Сигнал Start подано
    return 0;    
}

Якщо між переключенням стану лінії:
 DDRD &= ~(_BV(SDA_pin))
та читанням з неї:
if( not (PIND & _BV(SDA_pin)) )
не зробити затримку, лінії не встигне перейти в стан 1 і прочитається 0. Тому вартує певної уваги функція stabilization_delay. Її роль -- дати достатню затримку, щоб лінія встигла стабілізуватися. Зокрема, згідно даташітів, на "внутрішню" стабілізацію, між зміною режиму ніжки та надійним до неї звертанням, слід використати мінімум один nop -- команду відсутності операції, яка триває один такт. Ще кілька тактів потрібно, щоб зарядити паразитну ємність шини. Як показали мої досліди, за довжини провідників порядку 5см, та напруги живлення 3.3В, потрібно 4 такти (1/4 мкс), за напруги 5В -- 3 такти (3/16 мкс). Реалізація функції тривіальна:

//Затримка на час стабілізації лінії
 inline void stabilization_delay()
{
    // Робити мікросекундну затримку для цього занадто марнотратно
    _NOP();
    _NOP();
    _NOP();
    _NOP();
    // Кожна nop, яку вставляє _NOP() займає 1 такт, на частоті 16МГц -- 1/16 мікросекунди 
    // Час на виклик stabilization_delay не враховується -- компілятор зазвичай поважає inline
    
    // Експериментально, на 3.3В треба 4 nop, на 5В -- 3
}

inline просить компілятор вбудувати тіло функції в місці виклику, щоб час на сам виклик не витрачався. (Компілятор може і проігнорувати, inline -- тільки рекомендація, але зазвичай дослухається.)
_NOP -- макрос із <avr/cpufunc.h>, який вставляє якраз одну команду nop.

Сигнал Stop відіслати теж просто:

// Посилає сигнал Stop
// Якщо все ОК, повертає 0
// Якщо лінія не піднялася для SDA -- повертає 3
// Якщо лінія не піднялася для SCL -- повертає 4
int send_stop()
{
    DDRD |= _BV(SDA_pin); // Опускаємо лінію SDA до 0    
    
    DDRD &= ~(_BV(SCL_pin)); // Піднімаємо лінію SCL до 1
    stabilization_delay();
    //Перевіримо, чи притягнуло:
    if( not (PIND & _BV(SCL_pin)) )
        return 4; // Лінію SCL насильно притиснуто до нуля
        
    _delay_us(def_delay);    
    DDRD &= ~(_BV(SDA_pin)); // Піднімаємо лінію SDA до 1
    stabilization_delay();
    if( not (PIND & _BV(SDA_pin)) )
        return 3; // Лінію SDA насильно притиснуто до нуля

    //Сигнал Stop подано
    return 0;    
}

Передача та отримання одного біта не складніші:

// Записуємо один біт
// Вважає, що SCL=0 на початку
// Повертає 0, якщо все добре
// 5 -- не вдалося виставити одиничний біт на SDA
// 6 -- не вдалося виставити одиничний біт на SCL
int send_bit(uint8_t bit_val)
{
    bit_val &=0x01; // Про всяк випадок обнуляємо старші біти
    if(bit_val)
    {    // Відпускаємо лінію - підтягнеться до 1
        DDRD &= ~(_BV(SDA_pin)); 
    }else{
        DDRD |= _BV(SDA_pin); // Притискаємо лінію до 0
    }
    // _NOP();// Даємо час прийти в рівновагу - див. даташіт
    // Одного nop контролеру може і досить, шині - ні
    stabilization_delay(); // Час на стабілізацію    
    // Користуючись нагодою, перевіримо, чи вдалося підняти, якщо передавали 1
    if( bit_val and not (PIND & _BV(SDA_pin)) )
        return 5; // Лінію SDA насильно притиснуто до нуля
    
    // Відпускаємо SCL до 1
     DDRD &= ~(_BV(SCL_pin)); 
     stabilization_delay(); // Час на стабілізацію лінії
    // Перевіряємо, чи вдалося
    if( not (PIND & _BV(SCL_pin)) )
        return 6; // Лінію SCL насильно притиснуто до нуля
    // Чекаємо:
    _delay_us(def_delay);
    //Завершуємо передачу, SCL=0
    DDRD |= _BV(SCL_pin); 
    return 0;    
}    

// Читає один біт
// Вважає, що SCL=0 на початку
// Повертає 0, якщо все добре
// 7 -- таймаут очікування на лінію SCL
int get_bit(uint8_t *bit_val)
{
    //Переключаємо SDA-пін на ввід -- нам з нього читати.
    DDRD &= ~(_BV(SDA_pin));

    //Виставляємо 1 на SCL
    DDRD &= ~(_BV(SCL_pin));
    const int timeout_counter=20;
    int i;
    for(i=0;i<timeout_counter;++i)
    { 
        if( not( PIND & _BV(SCL_pin) ) )
        {
            _delay_us(def_delay);
            ++i;
        }else{
            break;
       }
    }
  
    if(i==timeout_counter)
    { // Лінію нам не віддали - таймаут
        return 7;
    }
    // Читаємо, що там нам передали
    
    *bit_val= ( PIND & _BV(SDA_pin) ) >> SDA_pin;
    // Повертаємо SCL в нуль
    DDRD |= _BV(SCL_pin);
    return 0;    
}

Намагаючись отримати біт, перевіряємо, чи лінію SCL відпустили -- раптом пристрій ще не готовий до спілкування.

Відсилання/отримання цілих байтів, з використанням описаних вище функцій, тривіальне:

// Записує байт, перевіряє ACK
// Повертає 0, якщо все ОК
// Повертає коди помилок функцій, що викликає,
// або 8, якщо у відповідь на передані дані
// отримав NACK
// Коди помилок спеціально зроблені скрізьними і різними
// щоб зразу було видно, де збій.
int send_byte(uint8_t byte_for_send)
{
    int res;
    for(int8_t i=7;i>=0;--i)
    {
        // Зсуваємо потрібний біт на 0-ву позицію
        uint8_t cur_bit = byte_for_send >> i;
        // Зануляємо всі решта
        cur_bit &= 0x01;
        //Відсилаємо
        res=send_bit(cur_bit);
        if(res!=0)
        {
              send_stop();//Помилка. Пробуємо послати Stop
              return res;
          }
    }
    // Перевіряємо, чи є ACK
    uint8_t in_bit;
    res=get_bit(&in_bit);
    if(res!=0)
    {
          send_stop();//Помилка. Пробуємо послати Stop
          return res;
    }
    if(in_bit!=0)
    {   // Маємо ситуацію NACK
          return 8;
    }
    
    return 0;    
}

// Читаємо байт, в залежності від аргумента посилає ACK
// Повертає 0, якщо все ОК
// Повертає коди помилок функцій, що викликає,
// або 8, якщо у відповідь на передані дані
// Коди помилок спеціально зроблені скрізьними і різними
// щоб зразу було видно, де збій.
int receive_byte(uint8_t *in_byte, int sendACK)
{
    int res;
    *in_byte=0;
    for(int8_t i=7;i>=0;--i)
    {
        uint8_t in_bit;
        res=get_bit(&in_bit);
        if(res!=0)
        {
              send_stop();//Помилка. Пробуємо послати Stop
              return res;
        }
        // Зсуваємо вже отримані біти
        *in_byte = *in_byte << 1;
        // Встановлюємо новий отриманий
        *in_byte+=in_bit;
    }
    if(sendACK)
    {
        res=send_bit(0);
        if(res!=0)
        {
              send_stop();//Помилка. Пробуємо послати Stop
              return res;
        }
    }else{ // Посилаємо NACK
    res=send_bit(1);
    if(res!=0)
    {
        send_stop();//Помилка. Пробуємо послати Stop
        return res;
    }
    }
        
    return 0;    
}

Повний тест програми тут не наводжу. Вона складається із кількох файлів -- для роботи з UART, для роботи з I2C, та головного файлу програми, тому буде громіздко, а ключові її елементи наведено вище. Якщо комусь цікаво, проект для AVR Studio (як вона тепер називається, Atmel Studio) можна взяти тут.

Підсумок


Реалізація, описана вище трішки груба. З одного боку, заради простоти, з іншого -- див. заголовок блогу, я не дуже спеціаліст ні в програмуванні контролерів, ні в роботі з I2C. Але вона працює, і мала б непогано ілюструвати головні принципи.

Одне важливе питання -- частота, на якій працюватиме шина I2C. Вона, звичайно, дуже толерантна до часових варіацій, але далеко не всі шини такі. Отож, час виконання кожного етапу, наприклад читання біта, включатиме одну затримку, величиною  def_delay мікросекунд, плюс затримки на стабілізації та на виконання команд самої програми. Команд, разом із nop-ами для стабілізації буде десяток-другий, що складатиме 0.6-1.3 мікросекунд. Взявши затримку def_delay тривалістю 1 мкс, матимемо час одного такту на шині приблизно 2мкс, що відповідає частоті 500 кГц, яка, правда, буде доволі помітно варіювати (на око - десятки процентів, детально не досліджував) на різних етапах. Вже цілком непогано, в порівнянні з Ардуїнівською реалізацією. Крім того, трішки похитрувавши із затримками та викинувши перевірку стану лінії, її можна ще й збільшити. Правда, для покращення стабільності, доведеться, напевне, писати на асемблері (або дуууже акуратно на С, спочатку розібравшись із особливостями роботи компілятора, та перевіряючи, що ж він нагенерував).

Однак, багато контролерів, в тому числі й ATmega2560, мають спеціальну апаратуру для роботи із шиною I2C, в термінології Атмель -- TWI. Якщо користуватися нею, багато якими проблемами реалізації перейметься сам контролер. Роботу із нею буде розглянуто в наступній нотатці. Котрий спосіб кращий, програмний, розглянутий тут, чи апаратний, залежить від ситуації та вподобань розробника.



Виноски


(*1) І все це не правда, що контролери не можна програмувати за допомогою C++. Інша справа, що слід поводитися акуратно -- не робити речей, які контролери не дуже добре вміють. Наприклад, динамічне керування пам'яттю. Неконтрольоване роздування шаблонів теж буде неприємним. Воно, звичайно, завжди неприємне, але одна справа -- гігабайти пам'яті для програм, інша -- кілобайти. Про суміжне питання -- написання ядра операційної системи на C++ див. тут. Там звертається увагу на багато речей, які слід мати на увазі.

(*2) Певні відмінності між різними компіляторами, звичайно, є. Отримати уявлення про них можна, наприклад, у статті "Porting From IAR to AVR GCC".

(*3) В принципі, avr-libc має свої засоби вводу-виводу, аж до функцій сімейства printf. Поки не випробовував.

Немає коментарів:

Дописати коментар