Си-строки (массивы символов)
Си-строки
В прошлом уроке мы разобрали динамические String-строки в реализации Arduino, а сейчас настало время стандартных статических строк языка C/C++. Такая строка представляет собой массив символов типа char
(char array) и для неё работает такой же синтаксис, как и для остальных массивов (урок про массивы). Конец строки определяется нулевым символом \0
(или целым число 0
), за это такой тип строк называют null-terminated string: ноль на конце позволяет программе определять конец строки и её длину. Также это стандартные строки языка Си и поэтому называются cstring.
Текст в кавычках
Любой написанный в двойных кавычках текст "some text"
:
- Является строковой константой - string constant
- Имеет тип данных
const char*
- то есть указывает на свой первый символ в памяти - Хранится и в программной, и в оперативной памяти микроконтроллера
- Компилятор автоматически добавляет нулевой символ в конец строки
'\0'
- то есть реальный размер строки всегда на 1 символ больше - Оптимизируется компилятором - об этом ниже
Оптимизация компилятором
Компилятор оптимизирует строковые константы, но не во всех случаях. Если создать несколько строк как массивы (которые можно изменять) и присвоить им одинаковые строки, то они займут место в памяти как разные строки, т.е. столько, сколько в них суммарно символов:
char s1[] = "hello"; char s2[] = "hello";
Если создать несколько одинаковых строк как указатели - то компилятор их оптимизирует и они займут место в памяти как одна строка!
const char* s1 = "hello"; const char* s2 = "hello";
Если при выводе в Serial или передаче в другие функции мы используем одинаковые строки, то они также будут оптимизироваться и занимать место как одна строка:
Serial.println("hello"); lcd.print("hello"); String s("hello");
В то же время F()
- строки (подробнее в уроке про PROGMEM) не оптимизируются компилятором и занимают в программной памяти каждая своё место:
Serial.println(F("hello")); lcd.print(F("hello"));
Сложение
Строковые константы можно складывать через пробелы:
char str[] = "Hello" ", " "World!";
Сложение происходит на этапе компиляции, то есть в скомпилированной программе это будет одна общая строка.
Перенос строк (в программе)
Длинную строку можно переносить для удобства чтения и редактирования программы. Есть два способа:
Первый способ - работает как сложение строк в предыдущей главе. Каждая строка в своих кавычках пишется с новой строки:
char str[] = "Hello" ", " "World!";
Второй способ - использование символа обратный слэш \
для переноса строки. Кавычки в этом случае нужны только в начале и конце:
char str[] = "Hello\ , \ World!";
Примечание: результирующий текст в переменной str
в обоих случаях не имеет переносов, то есть в обоих примерах получится строка "Hello, World!"
.
Перенос строк (текст)
Для человека текст с новой строки - это текст с новой строки. Чтобы перенести текст на новую строку, мы нажимаем на клавишу Enter на клавиатуре. В то же время текст в текстовых файлах не хранится в разных "строках", он лежит в памяти одной длинной строкой. Когда мы открываем файл, компьютер читает текст и ищет в нём специальные невидимые символы, которые называются управляющими символами. Одним из таких символов является перенос строки - \n
, именно его добавляет клавиша Enter. Чтобы компьютер при выводе строки перенёс её - нужно добавить этот символ в текст. В программе мы будем видеть этот символ, а вот в результирующем тексте он автоматически превратится в перенос строки. Примеры (без переноса в программе и с переносом двумя способами):
char str1[] = "Строка1\nСтрока2\nСтрока3"; char str2[] = "Строка1\ \nСтрока2\ \nСтрока3"; char str3[] = "Строка1\n" "Строка2\n" "Строка3";
Во всех трёх случаях получится текст
Строка1 Строка2 Строка3
Примечание: ставить символ переноса строки можно как в начале новой строки (см. str2
), так и в конце предыдущей (см. str3
).
Кавычки внутри строки, экранирование
На практике довольно часто бывает нужно иметь строку, которая содержит символы двойных кавычек, например для вёрстки html:
<div class="button"></div>
Строка ограничивается символами двойные кавычки "
: с них начинается и заканчивается. В программе не может быть просто отдельно стоящих двойных кавычек "
- это приведёт к ошибке компиляции, так как они являются частью синтаксиса, который обязывает использовать их "парами"
. Соответственно нельзя просто так взять и задать строку, содержащую этот символ внутри себя:
// приведёт к ошибке компиляции char str1[] = "Символ " кавычка"; // приведёт к ошибке компиляции char str2[] = "Этот "текст" в кавычках"; // скомпилируется, получится Этот текст в кавычках (сложение трёх строк) char str3[] = "Этот ""текст"" в кавычках";
Эту проблему можно решить двумя способами: экранированием и использованием инструмента компилятора raw string literal (С++ 11). Экранирование кавычек во многих языках программирования осуществляется при помощи обратного слэша \
. Таким образом просто кавычки "
- это оператор, часть синтаксиса языка, а вот так - \"
- это печатный символ кавычек, который может входить в состав строки:
// скомпилируется, получится Этот "текст" в кавычках char str4[] = "Этот \"текст\" в кавычках";
“Сырые” строки
"Сырые" строки - очень удобный инструмент компилятора, позволяющий задать любой текст просто в виде текста, включая кавычки и переносы строк без дополнительного экранирования. Синтаксис следующий R"(ваш текст)"
или R"метка(ваш текст)метка"
, где метка - любой текст длиной до 16 символов без пробелов, должен быть одинаковым в начале и конце. Нужна для того, чтобы компилятор мог корректно определить конец сырой строки, если внутри самой строки есть )"
. Например строка R"(<tag onclick="func()" class="b1">)"
приведёт к ошибке, т.к. компилятор решит что она закончилась после слова func
! Добавим метку R"raw(<tag onclick="func()" class="b1">)raw"
и компилятор без ошибки найдёт конец сырой вставки. Примеры:
// вывод: текст "с кавычками" - удобно Serial.println(R"(текст "с кавычками" - удобно)"); char str1[] = R"(текст с переносами строки)"; Serial.println(str1); // метку rawliteral часто можно встретить в примерах для esp8266/32. char str2[] = R"rawliteral(<!DOCTYPE HTML><html><head> <meta name="viewport" content="width=device-width, initial-scale=1"> </head><body> </body> </html>)rawliteral"; Serial.println(str2);
Примечание: перенос строки внутри экранированной строки в программе станет переносом строки в итоговой строке в переменной!
Массив символов
Объявление как массив
Основное отличие таких строк от String
-строк: это обычный массив, размер которого известен заранее и не меняется в процессе работы. Можно объявить строку как массив и посимвольно задать текст:
char str[] = {'h', 'e', 'l', 'l', 'o', 0}; // с нулевым символом на конце
Такой вариант записи не очень удобный, поэтому строки в C/C++ можно задавать просто текстом в двойных кавычках - компилятор сам посчитает размер массива:
char str[] = "hello";
Полученный выше массив содержит 6 символов: 5 на слово hello и 1 на завершающий символ. Текст в данном массиве можно изменять в процессе работы программы, потому что с точки зрения программы мы создали обычный массив и заполнили его буквами. Изменим первую букву на прописную: str[0] = 'H';
. Выведем в монитор порта:
Serial.println(str);
Serial
умеет работать с такими данными и с радостью их выведет.
Объявление как указатель
Также строку можно объявить как указатель на const char*
- то есть сам текст в кавычках хранится где то в программе, а мы получаем на него "ссылку":
const char* str = "hello";
Текст в такой строке менять уже нельзя, но можно использовать дальше в программе для сложения или вывода:
Serial.println(str);
Примечание: можно объявить и как char* str = "hello";
и пользоваться дальше точно так же как массивом, но компилятор выдаст предупреждение что строковая константа (текст в кавычках) приравнивается к неконстантному типу.
Массив строк
Можно создать один массив с несколькими строками и обращаться к ним по индексу, фактически это будет двухмерный массив (массив массивов). Выглядит следующим образом:
// объявляем массив строк const char* names[] = { "Period", // 0 "Work", // 1 "Stop", // 2 }; // выводим третий элемент Serial.println(names[2]); // выведет Stop
Таким образом удобно паковать строки для создания текстовых меню и прочего. Единственный большой минус - весь этот текст висит в оперативной памяти мёртвым грузом. Можно сохранить его во Flash - программной памяти (PROGMEM), об этом читайте в отдельном уроке.
Точно также можно создать массив массив пустых строк для дальнейшей работы:
char arr[к-во строк][макс. длина];
По сути это будет двухмерный массив. Копирование другой строки в массив может выглядеть так: strcpy(arr[0], str);
. Об этом читайте ниже.
Длина строки
Для определения длины текста можно использовать оператор strlen()
, который возвращает количество символов в строке. Сравним его работу с оператором sizeof()
:
char str[100] = "World"; sizeof(str); // вернёт 100 strlen(str); // вернёт 5
Здесь оператор sizeof()
вернул количество байт, занимаемое массивом. Массив я специально объявил с размером бОльшим, чем содержащийся в нём текст. А вот оператор strlen()
посчитал и вернул количество символов, которые идут с начала массива и до нулевого символа в конце текста без его учёта. А вот такой будет результат при инициализации без указания размера массива:
char text[] = "Hello"; strlen(text); // вернёт 5 ("читаемых" символов) sizeof(text); // вернёт 6 (байт)
Отличия от String
В отличие от String-строк, Си-строки:
char str[] = "hello"; char str2[] = "world"; str += str2; // НЕЛЬЗЯ складывать str = "text"; // НЕЛЬЗЯ присваивать после инициализации if (str == str2); // НЕЛЬЗЯ сравнивать
Для этого существуют специальные функции, о которых мы поговорим ниже.
Оптимизация памяти
Как я писал выше - "текст в кавычках" хранится и в памяти программы, и в оперативной памяти, то есть после запуска микроконтроллера строка загружается в оперативную память, и уже там мы имеем к ней доступ. Как правило, объём программной памяти микроконтроллера в несколько раз больше, чем оперативной. Есть несколько возможностей хранения строк только в программной памяти, об этом очень подробно поговорим в уроке про PROGMEM.
Инструменты для Си-строк
Массивы символов не так просты, как кажутся: их возможности сильно расширяет стандартная библиотека cstring. Использование всех доступных фишек по работе с массивами символов позволяет полностью избавить свой код от тяжёлых String-строк и сделать его легче, быстрее и оптимальнее. Подробно обо всех инструментах можно почитать в официальной документации. Очень интересный пример с манипуляцией этими инструментами можно посмотреть здесь. А мы вкратце рассмотрим самые полезные.
Конвертирование
Есть готовые функции, позволяющие конвертировать различные типы данных в строки:
itoa(int_data, str, base)
- записывает переменную типаint
int_data в строку str с базисом* base.utoa(uint_data, str, base)
- записывает переменную типаunsigned int
uint_data в строку str с базисом* base.ltoa (long_data, str, base)
- записывает переменную типаlong
long_data в строку str с базисом* base.ultoa (unsigned_long_data, str, base)
- записывает переменную типаunsigned long
unsigned_long_data в строку str с базисом* base.dtostrf(float_data, width, dec, str)
- записывает переменную типаfloat
float_data в строку str с количеством символов width и знаков после запятой dec.
* Примечание: base - основание системы счисления, тут всё как при выводе в Serial:
DEC
- десятичнаяBIN
- двоичнаяOCT
- восьмеричнаяHEX
- шестнадцатеричная
float x = 12.123; char str[10] = ""; dtostrf(x, 4, 2, str); // тут str == "12.12" int y = 123; itoa(y, str, DEC); // тут str == "123"
И наоборот, можно преобразовывать строки в численные данные, функция вернёт результат:
atoi(str)
- преобразование str вint
atol(str)
- преобразование str вlong
atof(str)
- преобразование str вfloat
float x; char str[10] = "12.345"; x = atof(str); // тут x == "12.345"
Работа с байтовым буфером
Очень часто в реальных задачах встречается ситуация, когда текстовые данные приходят в виде массива byte
: по какому-нибудь каналу связи (MQTT, UDP, Bluetooth...), при чтении из файлов и так далее. Например приём по MQTT во многих библиотеках выглядит так:
void callback(byte* payload, uint16_t len) { }
Пришёл поток байтов известной длины. Что с ними делать, если это текст и нам в программе он нужен как строка? Во многих примерах в Интернете предлагают преобразовать данные в String
, просто как String s = (char*)payload
. Делать так категорически нельзя, если переданный текст не оканчивается нулевым символом, а в большинстве случаев это как раз так. Дело в том, что свободная оперативная память во время работы микроконтроллера содержит не нули, а фактически случайные значения, оставшиеся от выгруженных переменных в разных местах программы. И если у нас приходит массив, который не оканчивается нулём, то в памяти после него тоже не обязан быть ноль, и при преобразовании в строку пойдёт вся память по порядку, пока не встретится ноль. Простой пример:
char str0[] = {'a', 'b', 'c'}; char str1[] = {'d', 'e', 'f'}; char str2[] = {'g', 'h', 'i'}; Serial.println(str0); // abc Serial.println(str1); // defabc Serial.println(str2); // ghidefabc
Отсюда видно, что в строку пойдут любые данные из памяти, пока не встретится ноль. Что делать? Варианта два.
Через String
Если нужна String-строка, то нужно её создать, зарезервировать место под текст (чтобы избежать лишних аллокаций) и переписать в неё данные. К сожалению в реализации Arduino функция для переписывания массива в строку сделана приватной, поэтому придётся просто прибавить данные в цикле. Этот способ делает в два раза больше действий, чем могло бы быть, но для String-строки это единственный способ:
void callback(byte* payload, uint16_t len) { String s; s.reserve(len + 1); for (uint16_t i = 0; i < len; i++) s += (char)payload[i]; }
Через cstring
Здесь алгоритм будет такой: создать массив char
с запасом под нулевой символ, переписать в него данные и нулевой символ в конце:
void callback(byte* payload, uint16_t len) { char str[len + 1]; strncpy(str, (char*)payload, len); str[len] = 0; }
Данный способ сильно быстрее и эффективнее чем String. Дальше можно работать с созданной строкой как обычно.
Прочее
Инструменты для копирования, поиска и сравнения
strcpy(str1, str2)
|
Копирует str2 в str1, включая NULL. Так как мы передаём указатель, цель и место назначения можно "подвинуть": char str1[] = "hello world"; char str2[] = "goodbye"; // вставим bye после hello strcpy(str1 + 6, str2 + 4); // тут str1 == hello bye |
strncpy(str1, str2, num)
|
Копирует num символов из начала str2 в начало str1 char str1[] = "hello world"; char str2[] = "goodbye"; // вставим good после hello strncpy(str1 + 6, str2, 4); // тут str1 == hello goodd // вторая d осталась после "world" |
strcat(str1, str2)
|
Прибавляет str2 к str1, при этом str1 должна иметь достаточный для этого размер. NULL первой строки заменяется на первый символ из str2
char str1[15] = "hello "; char str2[] = "world"; strcat(str1, str2); // здесь str1 - "hello world" |
strncat(str1, str2, num)
|
Добавляет num символов из начала str2 к концу str1
|
strcmp(str1, str2)
|
Сравнивает str1 и str2. Возвращает 0, если строки одинаковы. Больше нуля, если str1 > str2. Меньше нуля, если str1 < str2.
|
strncmp(str1, str2, num)
|
Сравнивает первые num символов из строк str1 и str2. Возвращает 0, если эти участки одинаковы.
|
strchr(str, symb)
|
Ищет символ symb в строке str и возвращает указатель на первое совпадение.
|
strrchr(str, symb)
|
Ищет символ symb в строке str и возвращает указатель на последнее совпадение.
|
strcspn(str1, str2)
|
Выполняет поиск первого вхождения в строку str1 любого из символов строки str2 и возвращает количество символов до найденного первого вхождения.
|
strpbrk(str1, str2)
|
Выполняет поиск первого вхождения в строку str1 любого из символов строки str2 и возвращает указатель на найденный символ.
|
strspn(str1, str2)
|
Поиск символов строки str2 в строке str1. Возвращает длину начального участка строки str1, который состоит только из символов строки str2.
|
strstr(str1, str2)
|
Функция ищет первое вхождение подстроки str2 в строке str1.
|
strtok(str, delim)
|
Ищет символы-разделители delim в строке str, возвращает указатель на последний найденный. Как использовать - смотри тут.
|
strlen(str)
|
Возвращает длину строки str без учёта нулевого символа.
|
strdup(str)
|
Дублирует указанную str строку, динамически выделяя память под новую строку, возвращает указатель на новую строку. Внимание! Новая строка будет в динамической памяти, чтобы удалить такую строку - нужно использовать оператор
delete или free . |
Библиотека
У меня есть библиотека для удобной работы с Си-строками, по возможностям схожая со String, но гораздо легче и эффективнее. Библиотека называется mString, документацию и примеры смотрите на GitHub.
Видео
Полезные страницы
- Набор GyverKIT – большой стартовый набор Arduino моей разработки, продаётся в России
- Каталог ссылок на дешёвые Ардуины, датчики, модули и прочие железки с AliExpress у проверенных продавцов
- Подборка библиотек для Arduino, самых интересных и полезных, официальных и не очень
- Полная документация по языку Ардуино, все встроенные функции и макросы, все доступные типы данных
- Сборник полезных алгоритмов для написания скетчей: структура кода, таймеры, фильтры, парсинг данных
- Видео уроки по программированию Arduino с канала “Заметки Ардуинщика” – одни из самых подробных в рунете
- Поддержать автора за работу над уроками
- Обратная связь – сообщить об ошибке в уроке или предложить дополнение по тексту ([email protected])