Указатели


Указатели – одна из самых сложных тем в программировании, постараюсь рассказать её попроще и ближе к практике. Начнём с представления данных в памяти микроконтроллера: в прошлом уроке про битовые операции мы обсуждали, что минимальным адресуемым блоком памяти является байт, то есть мы можем обратиться к любому байту в памяти микроконтроллера. Работая с переменными, мы не задумываемся об адресах и расположении данных в памяти, мы просто используем их имена для чтения/записи, передаём имена в качестве параметров в функции и совершаем прочие действия с данными. Владение адресами блоков данных позволяет делать многие вещи более быстро и эффективно с точки зрения памяти. Несколько примеров возможностей, которые дают указатели:

  • При помощи указателей можно разбивать любые данные (переменные всех типов, структуры) на биты (битовые потоки) для последующей манипуляции с ними (передача/запись/чтение);
  • Можно передавать адреса блоков данных в качестве аргументов в функции, таким образом при вызове функции не создаются копии переменных и код работает быстрее. Другими словами – дать возможность функции менять передаваемый аргумент;
  • Работа с динамической памятью “напрямую”, создание динамических массивов любого размера с быстрым доступом к данным. Читай урок про динамическую память.

Что же такое указатель? Это переменная, которая содержит адрес области данных (переменной/структуры/объекта/функции и т.п.) в памяти микроконтроллера, точнее – его самого первого блока, байта. Мы знаем, что все данные состоят из байтов, из разного их количества, зная адрес первого байта в блоке данных, можно получить контроль над данными по этому адресу, но нужно знать размер этого блока. Собственно при создании указателя мы указываем, на какой тип данных он указывает, указатель может указывать на любой тип данных. Сам по себе указатель хранит адрес, прибавив к указателю 1 мы получим адрес следующего блока памяти.

Перейдём к операторам, которые позволяют работать с указателями.

“Адресные” операторы


Операторов у нас немного, но они являются одной из самых мощных “фишек” языка С++:

  • & – возвращает адрес данных в памяти (адрес первого блока данных)
  • * – возвращает значение по указанному адресу
  • -> – оператор косвенного обращения к членам и методам (для указателей на структуры и классы). Является короткой записью конструкции через указатель: a->b равносильно (*a).b

Как это работает? Мы можем создать указатель на нужный тип данных вот таким образом:

тип_данных* имя_указателя;
тип_данных * имя_указателя;
тип_данных *имя_указателя;

Да, можно перепутать с умножением, но компилятор не перепутает. Все три варианта записи равносильны, в разных статьях/кодах можно встретить все три варианта, будьте к этому готовы. После объявления у нас с вами появляется указатель – переменная, которая может хранить адрес другой “переменной” указанного типа данных: это могут быть обычные переменные всех типов, массивы, строки, функции, структуры, объекты и даже пустота – void. Давайте отдельно рассмотрим указатели на разные типы данных, чтобы сразу охватить все их возможности.

Указатели на “обычные” переменные


Работа с указателем позволяет читать/менять значение переменной через её адрес. Смотрим пример с комментариями:

byte b;     // просто переменная типа byte
b = 10;     // b теперь 10
byte* ptr;  // ptr – переменная "указатель на объект типа byte"
ptr = &b;   // указатель ptr хранит адрес переменной b
*ptr = 24;  // b теперь равна 24 (записываем по адресу &b)
byte s;     // переменная s
s = *ptr;   // s теперь тоже равна 24 (читаем по адресу &b)

Вроде бы ничего сложного: создали указатель ptr на byte byte* ptr, и записали в него адрес переменной b: ptr = &b. Теперь мы имеем власть над переменной b через указатель ptr, можем менять её значение вот так: *ptr = значение.

Давайте попробуем передать адрес в функцию и изменить значение переменной по её адресу внутри функции. Пусть у нас будет функция, которая принимает адрес переменной типа int и возводит эту переменную в квадрат.

void square(int* val) {
  *val = *val * *val;
}

Вот так будем это использовать:

int value = 7;  // создали переменную
square(&value); // передали её адрес в функцию
// тут value уже == 49

Чем хорош этот подход? Мы не создавали копию переменной, как это делалось в уроке про функции, мы передали адрес и меняли значения напрямую. Вот о чём я говорю:

void setup() {
  int value = 7;  // создали переменную
  value = square(value);
  // тут value уже == 49
}

int square(int val) {
  return val*val;
}

Тут у нас в оперативной памяти создаётся копия переменной, мы с этой копией взаимодействуем, а затем возвращаем её обратно и присваиваем. Такой код выполняется гораздо медленнее!

Указатели на массивы


Про массивы у нас был отдельный урок, и там я не стал вас грузить и рассказывать “откуда берутся массивы”, потому что массивы – это на самом деле указатель и его друзья. Что такое вообще массив и как оно работает? Имя массива является указателем на первый элемент этого массива (тип элемента мы задаём при объявлении массива), то есть myArray[0] == *myArray, или так: myArray == &myArray[0]. Для упрощения написания и чтения кода введены квадратные скобки, а на самом деле это работает так: a[b] == *(a + b)! Массив – это область памяти, заполненная “переменными” указанного типа, и мы можем обращаться к ним по адресу! Пара примеров по работе с массивом без использования квадратных скобок:

void setup() {
  Serial.begin(9600);
  // работаем без [] скобок
  byte myArray[] = {1, 2, 3, 4, 5};

  // выведет 1 2 3 4 5
  for (byte i = 0; i < 5; i++) {
    Serial.print(*(myArray + i));
    Serial.print(' ');
  }
  Serial.println();

  // работаем с отдельным указателем
  int myArray2[] = {10, 20, 30, 40, 50};
  int* ptr2 = myArray2; // указатель на массив

  // выведет 10 20 30 40 50
  for (byte i = 0; i < 5; i++) {
    Serial.print(*ptr2);
    ptr2++;
    Serial.print(' ');
  }
}

Обратите внимание на второй пример: в цикле идёт увеличение указателя на единицу, ptr2++, таким образом и осуществляется “переключение” на следующий элемент массива!

Такое устройство массивов позволяет без лишних проблем передавать их в качестве аргументов в функции. Пример: функция, возвращающая сумму всех элементов в массиве:

void setup() {
  Serial.begin(9600);
  int myArray[] = {1, 2, 3, 4, 5, 6};
  Serial.println(sumArray(myArray));
}

int sumArray(int* arrayPtr) {  
  int sum = 0;
  // суммируем массив
  for (byte i = 0; i < 6; i++) sum += arrayPtr[i];
  return sum; // возвращаем
}

Важный момент: массив “не знает”, какого он размера, это всего лишь выделенная область памяти. Для универсальности такого подхода нужно обязательно знать размер массива заранее, или передавать его в качестве аргумента:

void setup() {
  Serial.begin(9600);
  int myArray[] = {1, 2, 3, 4, 5, 6};
  // выводим сумму массива
  Serial.println( sumArray(myArray, sizeof(myArray)) );
  // передали массив и его размер (в байтах!!!)
}

int sumArray(int* arrayPtr, int arrSize) {  
  int sum = 0;
  // находим размер массива в количестве элементов,
  // разделив размер в байтах на вес любого члена массива
  arrSize = arrSize / sizeof(arrayPtr[0]);
  
  // суммируем массив
  for (byte i = 0; i < arrSize; i++) sum += arrayPtr[i];
  return sum; // возвращаем
}

Важный момент: функция должна знать, какой тип массива в неё передаётся (в нашем случае int).

Указатель на функцию


Функция тоже имеет свой адрес в памяти, по которому к ней можно обратиться. По указателю функцию можно просто вызвать, а можно передать её в качестве аргумента в другую функцию, причём делать это можно в других файлах, в классы и библиотеки. Указатель на функцию объявляется так:

возвращаемый_тип_данных (*имя)(аргументы)

Затем указателю можно передать адрес любой функции (просто имя, оператор взятия адреса, как и в случае с массивами, не нужен):

void setup() {
  Serial.begin(9600);
  void (*ptrF)(byte a); // указатель на функцию (она объявлена ниже)
  ptrF = printByte;     // даём адрес функции printByte
  ptrF(125);    // вызов printByte через указатель (выведет 125)

  int (*ptrFunc)(byte a, byte b); // сделаем ещё указатель
  ptrFunc = sumFunc;    // на функцию sumFunc
  
  // вызовем printByte, которой передадим результат sumFunc 
  // через указатель ptrFunc
  ptrF(ptrFunc(10, 30));  // выведет 40
}

void printByte(byte b) {
  Serial.println(b);
}

int sumFunc(byte a, byte b) {
  return (a + b);
}

void loop() {}

Таким способом можно реализовать в библиотеке фишку в стиле “attachInterrupt” – хранить в классе адрес функции, и вызывать её из класса.

Указатель на структуры/классы


Структуры и классы (туда же перечисления) являются составными типами данных, механизм взаимодействия через указатели тут чуть отличается. Давайте создадим структуру, указатель на неё, и обратимся к структуре через него:

struct myStruct {
  byte myByte;
  int myInt;
};

// создадим структуру someStruct
myStruct someStruct;

// указатель типа myStruct* на структуру someStruct
myStruct* p = &someStruct;

// пишем по адресу в someStruct.myInt
p->myInt = -666;
//(*p).myInt = -666;  // или так, см. начало урока

Таким образом можно передавать большие структуры, не создавая их копии в формальных переменных – гораздо быстрее! С классом будет всё абсолютно то же самое.

Указатель на void


Во всех предыдущих примерах мы создавали указатель на заранее известный тип данных. А что делать, если хочется передать адрес на неизвестный тип данных? Можно сделать void* ptr – указатель на void, на любой тип! Но это только добавляет проблем, как нам дальше работать с этим указателем? Начнём с того, что “неизвестный” указатель можно позже преобразовать в любой нужный тип при помощи приведения типов:

float Fval = 0.254;   // переменная float
void* ptrV = &Fval;   // указатель на хз (дали ему float, он не против)

// создали Fptr - указатель на float
// и преобразовали неизвестный ptrV во float
float* Fptr = (float*)ptrV;
// теперь *Fptr равен 0.254

Здесь мы преобразовали ptrV, который был void* (указатель на void), в указатель на float при помощи (float*). Иногда это может быть удобно, например при передаче данных разного формата при помощи одной “универсальной функции”. Также можно встретить преобразование типа указателя через cast (подробнее смотри в уроке про типы данных):

float* Fptr = static_cast<float*>(ptrV);

Разбивка на байты


Иногда бывает нужно передать какой-нибудь кусок данных, а потом принять его с другой стороны. Либо записать эти данные на какой-нибудь внешний носитель (EEPROM, карта памяти и подобное), а потом считать его обратно. Нужен универсальный инструмент, который запишет любую дату, а потом корректно её прочитает. Для решения этой задачи можно использовать указатели, делается это следующим образом: создаём указатель на тип byte, и присваиваем ему адрес блока данных любого типа, выполнив преобразование (byte*), получим просто указатель на первый байт данных. Зная длину (размер в байтах) нашего куска данных, можем считать его побайтно, просто прибавляя к адресу единицу! Рассмотрим простой пример с разбиванием числа long на 4 байта при помощи указателей:

// большое число
uint32_t bigVal = 123456789;

// указатель ptrB на адрес &bigVal
// приведённый к (byte*)
byte* ptrB = (byte*)&bigVal;

// разбиваем на байты
byte bigVal_1 = *ptrB;
byte bigVal_2 = *(ptrB + 1);
byte bigVal_3 = *(ptrB + 2);
byte bigVal_4 = *(ptrB + 3);

// попробуем собрать обратно
// понадобится новая переменная 
// такого же типа, что первая (uint32_t)
uint32_t newBig;
// берём её адрес
byte* ptrN = (byte*)&newBig;

// и собираем 4 байта обратно!
*ptrN = bigVal_1;  
*(ptrN + 1) = bigVal_2;
*(ptrN + 2) = bigVal_3;
*(ptrN + 3) = bigVal_4;

// в этом месте newBig равна 123456789

Таким образом можно “разобрать” и “собрать” любой кусок данных (массив любого типа, структуру), зная его размер. 

Задачу можно решить более красиво, используя массив байтов для чтения и записи. Рассмотрим пример с приведением типа указателя через (byte*) и через void* и через template:

// буфер
byte buffer[20];

// структура для теста
struct myStruct {
  byte val1;
  int val2;
  long val3;
  float val4;
};

void setup() {
  // === тест с переменными ===
  long a = 123456789;
  long b = 0;

  // разбиваем блок данных a на байты
  // и сохраняем в buffer
  writeData((byte*)&a, sizeof(a));

  // собираем блок данных b
  // из буфера buffer
  readData((byte*)&b, sizeof(b));

  // здесь b == 123456789

  // === тест со структурами ===
  // создаём структуру
  myStruct transmit;
  // присваиваем значение члену val4
  transmit.val4 = 3.1415;

  // "приёмная" структура
  myStruct recieve;

  // разбиваем блок данных transmit на байты
  // и сохраняем в buffer
  writeData((byte*)&transmit, sizeof(transmit));

  // собираем блок данных recieve
  // из буфера buffer
  readData((byte*)&recieve, sizeof(recieve));
  
  // здесь recieve.val4 == 3.1415
}

void writeData(byte* data, int length) {
  int i = 0;
  while (length--) {
    buffer[i] = *(data + i);
    i++;
  }
}

void readData(byte* data, int length) {
  int i = 0;
  while (length--) {
    *(data + i) = buffer[i];
    i++;
  }
}

void loop() {}

// буфер
byte buffer[20];

// структура для теста
struct myStruct {
  byte val1;
  int val2;
  long val3;
  float val4;
};

void setup() {
  // === тест с переменными ===
  long a = 123456789;
  long b = 0;

  // разбиваем блок данных a на байты
  // и сохраняем в buffer
  writeData(&a, sizeof(a));

  // собираем блок данных b
  // из буфера buffer
  readData(&b, sizeof(b));

  // здесь b == 123456789

  // === тест со структурами ===
  // создаём структуру
  myStruct transmit;
  // присваиваем значение члену val4
  transmit.val4 = 3.1415;

  // "приёмная" структура
  myStruct recieve;

  // разбиваем блок данных transmit на байты
  // и сохраняем в buffer
  writeData(&transmit, sizeof(transmit));

  // собираем блок данных recieve
  // из буфера buffer
  readData(&recieve, sizeof(recieve));
  
  // здесь recieve.val4 == 3.1415
}

void writeData(void* data, int length) {
  uint8_t* dataByte = (uint8_t*)data;
  int i = 0;
  while (length--) {
    buffer[i] = *(dataByte + i);
    i++;
  }
}

void readData(void* data, int length) {
  uint8_t* dataByte = (uint8_t*)data;
  int i = 0;
  while (length--) {
    *(dataByte + i) = buffer[i];
    i++;
  }
}

void loop() {}

// буфер
byte buffer[20];

// структура для теста
struct myStruct {
  byte val1;
  int val2;
  long val3;
  float val4;
};

void setup() {
  // === тест с переменными ===
  long a = 123456789;
  long b = 0;

  // разбиваем блок данных a на байты
  // и сохраняем в buffer
  writeData(a);

  // собираем блок данных b
  // из буфера buffer
  readData(b);

  // здесь b == 123456789

  // === тест со структурами ===
  // создаём структуру
  myStruct transmit;
  // присваиваем значение члену val4
  transmit.val4 = 3.1415;

  // "приёмная" структура
  myStruct recieve;

  // разбиваем блок данных transmit на байты
  // и сохраняем в buffer
  writeData(transmit);

  // собираем блок данных recieve
  // из буфера buffer
  readData(recieve);
  
  // здесь recieve.val4 == 3.1415
}

template <typename T>
void writeData(T &data) {
  // указатель на 1-ый байт data
  byte* ptr = (byte*) &data;
  int i = 0;
  // размер данных (в байтах)
  int count = sizeof(T);
  while (count--) {
    buffer[i] = *(ptr + i);
    i++;
  }
}

template <typename T>
void readData(T &data) {
  byte* ptr = (byte*) &data;
  int i = 0;
  int count = sizeof(T);
  while (count--) {
    *(ptr + i) = buffer[i];
    i++;
  }
}

void loop() {}

Данные примеры отличаются только вариантом передачи аргумента-адреса и его обработки:

  • В первом случае мы приводим указатель к (byte*) при передаче аргумента в функцию. Также передаём размер блока данных при помощи sizeof
  • Во втором случае у нас void* и ему всё равно, какой тип данных ему передадут, далее мы переводим указатель в uint8_t через reinterpret_cast. Также передаём размер блока данных при помощи sizeof
  • Третий вариант – через шаблонную функцию, которой вообще без разницы, какой у данных тип и размер: она принимает данные по ссылке, далее делаем указатель на первый байт и прямо внутри функции вычисляем размер блока данных через sizeof. Это самый мощный и универсальный вариант.

Примечание: все три примера занимают одинаковый объём в памяти.

Данный урок является максимально коротким и “справочным”, более подробно об указателях, ссылках (мы их не разбирали) и их особенностях рекомендую почитать в справочнике по C++, в статье на Хабре про указатели и массивы и большой и сложной статье на тему указателей со сложными примерами.

Ссылки


Ссылки – по сути те же самые указатели, но с другим синтаксисом. Можно создать ссылку на другую переменную, но, в отличие от указателя, ссылка должна быть инициализирована при создании:

тип_данных &имя_ссылки = данные;

Ссылка может работать в тех же случаях, что и указатель:

  • “Перехватывать управление” данными, т.н. ссылка в качестве “псевдонима”, через неё можно читать и писать
  • Быть аргументом в функциях
  • Осуществлять боле удобный доступ к данным

В отличие от указателя, ссылка не может быть изменена, т.е. всегда указывает на те данные, которые ей показали при инициализации!

Ссылки на типы данных


Рассмотрим первый пример с указателями в этой главе, но с использованием ссылок, т.е. пример полностью аналогичный с точки зрения происходящего, но вместо указателей используются ссылки:

byte b;     // просто переменная типа byte  
b = 10;     // b теперь 10
byte &link = b;  // link – переменная "ссылка на объект типа byte"
link = 24;  // b теперь равна 24 (записываем через ссылку)
byte s;     // переменная s
s = link;   // s теперь тоже равна 24 (читаем по ссылке)

Вспомним пример с функцией, возводящей переменную в квадрат, и перепишем её через ссылки, т.е. передадим ссылку в качестве аргумента функции:

void setup() {
  int value = 7;  // создали переменную
  square(value);  // передали её в функцию
  // тут value уже == 49
}

void square(int &val) { // val - ссылка на аргумент!
  val = val * val;
}

Ссылка на строку


Строка тоже является объёмом данных, на который можно сослаться. Эту тему я вынес в отдельную главу, потому что она очень важная: строки являются очень тяжёлым инструментом, который при неправильном использовании может привести к внезапному переполнению оперативной памяти и микроконтроллер зависнет. Основной момент, где нам понадобятся адреса – передача строки в качестве аргумента. Если передавать строку “как строку” – в оперативной памяти будет создан новый экземпляр строки. Если память достаточно сильно занята, а строка большая –  может случиться беда. Простейший пример, функция принимает аргументом строку и просто выводит её в порт:

void setup() {
  Serial.begin(9600); // для отладки
  String myString = "hello world! Hello again!";
  printString(myString);
}

void printString(String string) {
  Serial.println(string);
}

void loop() {}

Оптимизировать такой сценарий очень просто: передавать не всю строку целиком, создавая её копию, а передавая только адрес через ссылку:

void setup() {
  Serial.begin(9600); // для отладки
  String myString = "hello world! Hello again!";
  printString(myString);
}

void printString(String &string) {
  Serial.println(string);
}

void loop() {}

Добавился оператор вот тут: void printString(String &string) , и всё! Такой вариант работы со строкой быстрее и безопаснее для тяжёлых скетчей и больших строк.

Ссылка на структуру


Использование ссылок позволяет упростить доступ ко вложенным структурам:

struct Values {
  int value1;
  float value2;
};

struct BigStruct {
  Values values;  // элемент типа Values
  int otherValue;
};

BigStruct myStruct;

// делаем ссылку на элемент float
float &link = myStruct.values.value2;

// присваиваем через ссылку
link = 3.14;

// здесь myStruct.values.value2 == 3.14

Важные страницы


  • Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
  • Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
  • Полная документация по языку Ардуино, все встроенные функции и макро, все доступные типы данных
  • Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
  • Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете