Перейти к содержанию

Язык C для микроконтроллеров

Чем программирование микроконтроллера на C отличается от программирования для компьютера: почему int на AVR 16-битный, а char на ARM беззнаковый, зачем нужен volatile и чего он не делает, как устроен доступ к регистрам в avr-libc и CMSIS, как держать константы во Flash и где прячется неопределённое поведение. Со ссылками на стандарт C11 и ABI платформ.

Обновлено Редакция GAW

Программа для микроконтроллера пишется на том же C, что и программа для компьютера, но условия другие: 8- или 32-битное ядро без операционной системы, регистры периферии по фиксированным адресам, прерывания, которые меняют данные в любой момент, и несколько килобайт ОЗУ. Ниже — то, что в этих условиях нужно знать о языке, со ссылками на стандарт C11 (черновик N1570) и на ABI avr-gcc и ARM.

Коротко

  • Размеры типов зависят от платформы: у avr-gcc int — 16 бит и char знаковый, у ARM (AAPCS) int — 32 бита и char беззнаковый. Пишите uint8_t, int16_t, uint32_t из <stdint.h>.
  • volatile обязателен для регистров и для переменных, общих с прерываниями; атомарности он не даёт.
  • Регистры — это обращения через указатель на volatile по фиксированному адресу; так устроены заголовки avr-libc и CMSIS.
  • Константы на AVR по умолчанию попадают в ОЗУ — для Flash нужен PROGMEM; у Cortex-M const остаётся во Flash.
  • Переполнение знакового типа — неопределённое поведение, беззнакового — счёт по модулю 2^N.

Типы и их размеры

Типavr-gccGCC для ARM (AAPCS32)
char1 байт, знаковый1 байт, беззнаковый
short2 байта2 байта
int2 байта4 байта
long4 байта4 байта
long long8 байтов8 байтов
указатель2 байта4 байта
double4 или 8 байтов — зависит от настройки компилятора8 байтов

Данные avr-gcc — из раздела ABI вики GCC, ARM — из таблицы соответствия типов стандарта AAPCS32. Отсюда два правила. Первое: используйте типы точной ширины uint8_t, int16_t, uint32_t из <stdint.h> — стандарт требует определить их на любой платформе, где есть целые такой ширины в дополнительном коде (C11, 7.20.1.1), а размер int на разных МК разный. Второе: не используйте «просто char» для чисел — на AVR и ARM он ведёт себя по-разному при значениях больше 127.

С 16-битным int на AVR связана частая ошибка: выражение uint16_t a = 300; uint32_t x = a * 1000; вычисляется в 16-битном int и переполняется до присваивания. Нужно расширить один из операндов: (uint32_t)a * 1000. Та же формула на ARM работает, потому что int там 32-битный, — и код, перенесённый с ARM на AVR, ломается молча.

Целочисленная арифметика

Перед вычислениями типы уже int продвигаются до int (C11, 6.3.1.1), так что uint8_t + uint8_t считается в int, и результат 200 + 100 = 300 не теряется, пока его не присвоили байту. Беззнаковые типы никогда не переполняются в смысле стандарта — результат берётся по модулю 2^N (6.2.5p9), поэтому счётчики, таймстемпы и сравнения вроде (uint32_t)(now - start) >= timeout надёжно работают и при переходе через ноль. Переполнение знакового типа — неопределённое поведение (6.5p5): компилятор может, например, считать, что i + 1 > i всегда истинно. Для счётчиков и всего, что может перейти через максимум, берите беззнаковые типы.

Тонкости сдвигов и инверсии маленьких типов разобраны в статье о битовых операциях.

volatile

Стандарт говорит, что объект с квалификатором volatile может меняться способами, неизвестными компилятору, поэтому каждое обращение к нему выполняется строго по правилам абстрактной машины (C11, 6.7.3p7). Компилятор не имеет права выбросить чтение «лишнего» флага, объединить две записи в одну или вынести чтение из цикла. Без volatile такой код может зависнуть:

volatile uint8_t data_ready;   /* set in an interrupt handler */

ISR(USART_RX_vect)
{
    data_ready = 1;
}

void wait_for_data(void)
{
    while (!data_ready) {     /* without volatile the compiler may read it once */
    }
    data_ready = 0;
}

volatile нужен в трёх случаях: регистрам периферии, переменным, которые меняет обработчик прерывания и читает основная программа (или наоборот), и переменным, которые меняет другое ядро или DMA. Ставить volatile везде «на всякий случай» не стоит — он запрещает оптимизации и раздувает код.

Атомарность

volatile не делает обращение неделимым. 8-битный AVR читает uint16_t или uint32_t за несколько команд, и если между ними сработает прерывание и изменит переменную, основная программа получит половину старого и половину нового значения. На 32-битном Cortex-M чтение выровненного uint32_t — одна команда, но операция counter++ и там состоит из чтения, сложения и записи.

В avr-libc для таких мест есть макрос ATOMIC_BLOCK из <util/atomic.h>: он запрещает прерывания на время блока и восстанавливает прежнее состояние флага I при выходе:

#include <util/atomic.h>

volatile uint32_t ticks;      /* incremented in a timer interrupt */

uint32_t get_ticks(void)
{
    uint32_t copy;
    ATOMIC_BLOCK(ATOMIC_RESTORESTATE) {
        copy = ticks;
    }
    return copy;
}

На Cortex-M то же делают функциями CMSIS __disable_irq() и __enable_irq() или сохранением и восстановлением регистра PRIMASK.

Доступ к регистрам

Регистр периферии — это ячейка по фиксированному адресу, поэтому доступ к нему — разыменование указателя на volatile нужного размера. В avr-libc макрос _SFR_IO8(адрес) разворачивается в _MMIO_BYTE() — *(volatile uint8_t *)(адрес) с поправкой на смещение регистров ввода-вывода, — и PORTB = 0x20; превращается в запись по адресу 0x25 у ATmega328P. В CMSIS для ARM регистры модуля описаны структурой, поля которой помечены квалификаторами __I, __O и __IO — это всё тот же volatile, — а имя модуля — указатель на структуру по базовому адресу: GPIOA->ODR.

/* the same idea without vendor headers */
#define PORTB_REG  (*(volatile uint8_t *)0x25)      /* ATmega328P: PORTB data address */

typedef struct {
    volatile uint32_t CRL, CRH, IDR, ODR, BSRR, BRR, LCKR;
} gpio_regs;                                         /* STM32F1 GPIO layout */
#define GPIOA_REGS ((gpio_regs *)0x40010800UL)

Своими определениями пользуются редко — заголовки производителя уже содержат все регистры и биты, — но понимать, что за GPIOA->ODR стоит обычный указатель, полезно при отладке и при чтении карты памяти. Сравнение HAL, LL и прямой работы с регистрами — в статье о HAL, LL и CMSIS.

Константы и Flash

У AVR память программ и память данных — разные адресные пространства, и обычный указатель указывает в ОЗУ. Поэтому все инициализированные данные, в том числе const, avr-gcc при старте копирует из Flash в ОЗУ. Таблица на килобайт съедает половину ОЗУ ATmega328P. Чтобы данные остались во Flash, их объявляют с атрибутом PROGMEM и читают специальными функциями (руководство avr-libc, «Data in Program Space»; там же описан второй способ — именованное адресное пространство __flash):

#include <avr/pgmspace.h>

static const uint8_t sine[256] PROGMEM = { 128, 131, 134 /* ... */ };

uint8_t sine_at(uint8_t i)
{
    return pgm_read_byte(&sine[i]);   /* LPM instruction, not a RAM load */
}

У ARM Cortex-M Flash, ОЗУ и периферия находятся в одном 32-битном адресном пространстве, и const-данные компоновщик оставляет во Flash — их читают обычным указателем, без специальных функций.

Плавающая точка

У AVR нет блока плавающей точки, и каждая операция с float выполняется программно — вызовом библиотечной подпрограммы вместо одной команды. У Cortex-M4F и M7 есть аппаратный FPU, но у M4F он одинарной точности: операции с float выполняются аппаратно, а с double — программно. Поэтому константы в формулах пишут с суффиксом f — 0.5f, а не 0.5, иначе выражение молча пересчитается в double. Там, где FPU нет, дробные числа часто удобнее хранить с фиксированной точкой.

Частые вопросы

Зачем нужен volatile в программе для микроконтроллера?

Он сообщает компилятору, что значение может измениться без его ведома — аппаратурой или обработчиком прерывания, — поэтому каждое обращение к переменной в программе должно превратиться в настоящее чтение или запись памяти (C11, 6.7.3). Без volatile компилятор вправе прочитать флаг один раз и крутить цикл while (!flag) бесконечно. Volatile нужен регистрам периферии и переменным, общим для основной программы и прерываний.

Делает ли volatile доступ атомарным?

Нет. Volatile только запрещает компилятору выкидывать и переупорядочивать обращения. 16-битная переменная на 8-битном AVR читается двумя командами, и прерывание между ними может изменить её наполовину. Такие обращения выполняют при запрещённых прерываниях — в avr-libc для этого есть ATOMIC_BLOCK(ATOMIC_RESTORESTATE) из <util/atomic.h>.

Какой размер у int на микроконтроллере?

Зависит от компилятора и платформы: у avr-gcc int 16-битный, у компиляторов для ARM по стандарту вызовов AAPCS — 32-битный. Поэтому в прошивках используют типы точной ширины из <stdint.h> — uint8_t, int16_t, uint32_t: их размер одинаков везде, где они определены.

Почему константный массив занимает ОЗУ на AVR?

У AVR раздельные адресные пространства памяти программ и данных, и обычный указатель на const указывает в ОЗУ. Поэтому avr-gcc копирует константы при старте в ОЗУ. Чтобы оставить таблицу во Flash, её объявляют с атрибутом PROGMEM и читают функциями pgm_read_byte() и подобными из <avr/pgmspace.h>. У ARM Cortex-M Flash и ОЗУ лежат в одном адресном пространстве, и const-данные читаются прямо из Flash обычным указателем.

Источники

  1. ISO/IEC 9899:201x (C11), Committee Draft N1570, April 2011 — 6.2.5p9 — беззнаковая арифметика по модулю 2^N; 6.3.1.1 — целочисленное продвижение; 6.5p5 — переполнение знакового типа — неопределённое поведение; 6.7.3p7 — volatile; 7.20.1.1 — типы точной ширины intN_t и uintN_t
  2. GCC Wiki: avr-gcc — ABI and implementation defined behaviour — размеры типов avr-gcc: char знаковый 1 байт, int 2, long 4, long long 8, double и long double — 4 или 8 байтов в зависимости от настройки
  3. Arm. Procedure Call Standard for the Arm Architecture (AAPCS32) — таблица соответствия типов C: char — беззнаковый байт, int и long — 32-битное слово, long long — двойное слово
  4. AVR-LibC: <avr/sfr_defs.h>, <util/atomic.h>, <avr/pgmspace.h> (github.com/avrdudes/avr-libc) — _SFR_IO8 и _MMIO_BYTE — доступ к регистрам через указатель на volatile; ATOMIC_BLOCK(ATOMIC_RESTORESTATE); PROGMEM и pgm_read_byte
  5. AVR-LibC User Manual: Data in Program Space — почему GCC кладёт const-данные в ОЗУ у гарвардского AVR; PROGMEM с pgm_read_*() и именованное адресное пространство __flash
  6. Arm. CMSIS 5: core_cm3.h и cmsis_gcc.h (github.com/ARM-software/CMSIS_5) — квалификаторы __I, __O, __IO — это volatile для полей структур регистров; __disable_irq, __enable_irq, __get_PRIMASK, __set_PRIMASK

Нашли ошибку?