Подробности реализации указателя в C

Я хотел бы знать архитектуры, которые нарушают предположения, которые я перечислил ниже. Кроме того, я хотел бы знать, является ли какое-либо из предположений ложным для всех архитектур (то есть, если какое-либо из них просто совершенно неверно).

  1. sizeof (int *) == sizeof (char *) == sizeof (void *) == sizeof (func_ptr *)

  2. Представление в памяти всех указателей для данной архитектуры одинаково независимо от типа данных, на который указывает.

  3. Представление указателя в памяти совпадает с целым числом той же длины в битах, что и архитектура.

  4. Умножение и деление типов данных указателя запрещено только компилятором. ПРИМЕЧАНИЕ: Да, я знаю, что это бессмысленно. Что я имею в виду - есть ли аппаратная поддержка, чтобы запретить это неправильное использование?

  5. Все значения указателя могут быть приведены к единственному целому числу. Другими словами, какие архитектуры все еще используют сегменты и смещения?

  6. Увеличение указателя эквивалентно добавлению sizeof(the pointed data type) на адрес памяти, сохраненный указателем. Если p является int32* затем p+1 равен адресу памяти через 4 байта p,

Я больше всего привык к тому, что указатели используются в непрерывном виртуальном пространстве памяти. Для такого использования я обычно могу считать их адресами в числовой строке. См. Переполнение стека вопрос Сравнение указателей.

11 ответов

Решение

Я не могу дать вам конкретные примеры всего этого, но я сделаю все возможное.

sizeof(int *) == sizeof(char *) == sizeof(void *) == sizeof(func_ptr *)

Я не знаю ни одной системы, где я знаю, что это ложно, но рассмотрим:

Мобильные устройства часто имеют некоторое количество постоянной памяти, в которой хранится программный код и тому подобное. Значения только для чтения (константные переменные) могут храниться в постоянной памяти. А поскольку адресное пространство ПЗУ может быть меньше, чем обычное адресное пространство ОЗУ, размер указателя также может быть другим. Аналогично, указатели на функции могут иметь разный размер, так как они могут указывать на эту постоянную память, в которую загружена программа, и которую нельзя изменить (иначе ваши данные не могут быть сохранены в ней).

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

Представление в памяти всех указателей для данной архитектуры одинаково независимо от типа данных, на который указывает.

Подумайте о членских указателях против обычных указателей. Они не имеют одинаковое представление (или размер). Указатель члена состоит из this указатель и смещение.

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

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

Зависит от того, как определяется эта длина в битах.:) Ан int на многих 64-битных платформах по-прежнему 32 бит. Но указатель составляет 64 бита. Как уже говорилось, процессоры с сегментированной моделью памяти будут иметь указатели, состоящие из пары чисел. Аналогично, указатели членов состоят из пары чисел.

Умножение и деление типов данных указателя запрещено только компилятором.

В конечном счете, указатели типов данных существуют только в компиляторе. Процессор работает не с указателями, а с целыми числами и адресами памяти. Так что больше нигде нельзя запретить эти операции над типами указателей. Вы также можете попросить ЦП запретить конкатенацию строковых объектов C++. Это невозможно, потому что строковый тип C++ существует только на языке C++, а не в сгенерированном машинном коде.

Однако, чтобы ответить на вопрос, что вы имеете в виду, посмотрите на процессоры Motorola 68000. Я считаю, что у них есть отдельные регистры для целых чисел и адресов памяти. Это означает, что они могут легко запретить такие бессмысленные операции.

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

Вы в безопасности там. Стандарты C и C++ гарантируют, что это всегда возможно, независимо от расположения пространства памяти, архитектуры ЦП и всего остального. В частности, они гарантируют отображение, определяемое реализацией. Другими словами, вы всегда можете преобразовать указатель в целое число, а затем преобразовать это целое число обратно, чтобы получить исходный указатель. Но языки C/C++ ничего не говорят о том, каким должно быть промежуточное целочисленное значение. Это зависит от отдельного компилятора и от целевого оборудования.

Увеличение указателя эквивалентно добавлению sizeof(указанного типа данных) к адресу памяти, сохраняемому указателем.

Опять же, это гарантировано. Если вы считаете, что концептуально указатель не указывает на адрес, он указывает на объект, тогда это имеет смысл. Добавление одного к указателю, очевидно, сделает его указателем на следующий объект. Если длина объекта составляет 20 байт, то при увеличении указателя он будет перемещен на 20 байт, чтобы он переместился к следующему объекту.

Если указатель был просто адресом памяти в линейном адресном пространстве, если он был в основном целым числом, то при увеличении его значение добавлялось бы к адресу 1, то есть он перемещался бы на следующий байт.

Наконец, как я уже упоминал в комментарии к вашему вопросу, имейте в виду, что C++ - это просто язык. Неважно, к какой архитектуре она компилируется. Многие из этих ограничений могут показаться неясными на современных процессорах. Но что, если вы ориентируетесь на ЦП прошлых лет? Что, если вы ориентируетесь на ЦП следующего десятилетия? Вы даже не знаете, как они будут работать, поэтому вы не можете много о них думать. Что если вы ориентируетесь на виртуальную машину? Уже существуют компиляторы, которые генерируют байт-код для Flash и готовы к запуску с веб-сайта. Что если вы хотите скомпилировать исходный код C++ в Python?

Соблюдение правил, указанных в стандарте, гарантирует, что ваш код будет работать во всех этих случаях.

Я не имею в виду конкретные примеры из реальной жизни, но "авторитет" - это стандарт Си. Если что-то не требуется стандартом, вы можете создать соответствующую реализацию, которая намеренно не соответствует любым другим предположениям. Некоторые из этих предположений верны большую часть времени только потому, что удобно реализовать указатель в виде целого числа, представляющего адрес памяти, который может быть непосредственно извлечен процессором, но это всего лишь следствие "удобства" и не может быть сохранено как универсальная правда.

  1. Стандарт не требуется ( см. Этот вопрос). Например, sizeof(int*) может быть неравным size(double*), void* гарантированно сможет хранить любое значение указателя.
  2. Не требуется стандартом. По определению, размер является частью представления. Если размер может быть другим, представление также может быть другим.
  3. Не обязательно. На самом деле "длина битов архитектуры" - это смутное утверждение. Что такое 64-битный процессор? Это адресная шина? Размер регистров? Шина данных? Какие?
  4. Нет смысла "умножать" или "делить" указатель. Это запрещено компилятором, но вы, конечно, можете умножить или разделить базовое представление (что на самом деле не имеет смысла для меня), что приводит к неопределенному поведению.
  5. Может быть, я не понимаю вашу точку зрения, но все в цифровом компьютере - это просто какое-то двоичное число.
  6. Да; вид. Это гарантированно указывает на местоположение, которое sizeof(pointer_type) дальше. Это не обязательно эквивалентно арифметическому добавлению числа (то есть, далее это логическое понятие. Фактическое представление зависит от архитектуры)

Для 6.: указатель не обязательно является адресом памяти. Смотрите, например, " Великий заговор указателей" от пользователя Stack Overflow jalf:

Да, я использовал слово "адрес" в комментарии выше. Важно понять, что я имею в виду под этим. Я имею в виду не "адрес памяти, по которому физически хранятся данные", а просто абстракцию "все, что нам нужно, чтобы найти значение. Адрес i может быть любым, но как только мы его получим, мы всегда сможем найти и изменить i. "

А также:

Указатель не является адресом памяти! Я упоминал об этом выше, но давайте скажем это снова. Указатели обычно реализуются компилятором просто как адреса памяти, да, но это не обязательно. "

Дополнительная информация об указателях из стандарта C99:

  • 6.2.5 § 27 гарантирует, что void* а также char* имеют идентичные представления, т.е. они могут использоваться взаимозаменяемо без преобразования, т.е. один и тот же адрес обозначается одним и тем же битовым шаблоном (что не обязательно должно быть верно для других типов указателей)
  • 6.3.2.3 §1 гласит, что любой указатель на неполный или тип объекта может быть приведен к (и из) void* и вернитесь снова и все еще действительны; это не включает указатели функций!
  • 6.3.2.3 §6 гласит, что void* может быть приведен к (и из) целых чисел, и 7.18.1.4 §1 предоставляет соответствующие типы intptr_t а также uintptr_t; проблема: эти типы являются необязательными - в стандарте явно указывается, что необязательно должен быть целочисленный тип, достаточно большой, чтобы фактически содержать значение указателя!

sizeof(char*) != sizeof(void(*)(void)? - Не на x86 в режиме 36-битной адресации (поддерживается практически на каждом процессоре Intel начиная с Pentium 1)

"Представление указателя в памяти совпадает с целым числом одинаковой длины в битах" - в любой современной архитектуре нет представления в памяти; помеченная память никогда не завоевывала популярность и уже устарела до стандартизации C. Память на самом деле даже не содержит целых чисел, только биты и, возможно, слова (не байты; большая часть физической памяти не позволяет читать только 8 бит).

"Умножение указателей невозможно" - семейство 68000; регистры адресов (те, которые содержат указатели) не поддерживали этот IIRC.

"Все указатели могут быть приведены к целым числам" - не на PIC.

"Увеличение T* эквивалентно добавлению sizeof(T) к адресу памяти" - правда по определению. Также эквивалентно &pointer[1],

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

Я думаю, что это предположение неверно, потому что на 80186, например, 32-битный указатель содержится в двух регистрах (регистр смещения и регистр сегмента), и какое половинное слово вошло в какой регистр имеет значение во время доступа.

Умножение и деление типов данных указателя запрещено только компилятором.

Вы не можете умножать или делить типы.;П

Я не уверен, почему вы хотите умножить или разделить указатель.

Все значения указателя могут быть приведены к единственному целому числу. Другими словами, какие архитектуры все еще используют сегменты и смещения?

Стандарт C99 позволяет хранить указатели в intptr_t, который является целочисленным типом. Так да.

Увеличение указателя эквивалентно добавлению sizeof(указанного типа данных) к адресу памяти, сохраняемому указателем. Если p равно int32*, то p+1 равно адресу памяти через 4 байта после p.

x + y где x это T * а также y целое число эквивалентно (T *)((intptr_t)x + y * sizeof(T)) насколько я знаю. Выравнивание может быть проблемой, но заполнение может быть предоставлено в sizeof, Я не совсем уверен.

Я не знаю о других, но для DOS предположение №3 не соответствует действительности. DOS является 16-битным и использует различные приемы, чтобы отобразить много более 16-битной памяти.

В 1950-х, 1960-х и 1970-х годах было много "адресно-ориентированных" архитектур. Но я не могу вспомнить какие-либо основные примеры, которые имели компилятор Си. Я вспоминаю машины ICL / Three Rivers PERQ в 1980-х годах, которые были адресными и имели доступное для записи хранилище управления (микрокод). В одном из его примеров был компилятор C и разновидность Unix под названием PNX, но компилятору C требовался специальный микрокод.

Основная проблема заключается в том, что типы char * на машинах с адресацией слов неудобны, однако вы их реализуете. Вы часто с sizeof(int *) != sizeof(char *)...

Интересно, что до C существовал язык BCPL, в котором основным типом указателя был адрес слова; то есть, увеличение указателя дало вам адрес следующего слова, и ptr!1 дал вам слово в ptr + 1, Был другой оператор для адресации байта: ptr%42 если я вспомню.

В общем, ответ на все вопросы - " да ", и это потому, что только те машины, которые реализуют популярные языки, непосредственно увидели свет и сохранились в текущем столетии. Хотя языковые стандарты оставляют за собой право изменять эти "инварианты" или утверждения, это никогда не происходило в реальных продуктах, за исключением возможных пунктов 3 и 4, которые требуют некоторого пересмотра, чтобы быть универсально верным.

Конечно, можно создавать сегментированные проекты MMU, которые примерно соответствуют архитектурам на основе возможностей, которые были популярны в академической среде в последние годы, но ни одна из таких систем обычно не использовалась с такими функциями. Такая система могла бы противоречить утверждениям, поскольку, вероятно, имела бы большие указатели.

В дополнение к сегментированным / функциональным MMU, которые часто имеют большие указатели, более экстремальные конструкции пытались кодировать типы данных в указателях. Немногие из них когда-либо были построены. (Этот вопрос поднимает все альтернативы базовой словесно-ориентированной архитектуре "указатель на слово".)

В частности:

  1. Представление в памяти всех указателей для данной архитектуры одинаково независимо от типа данных, на который указывает. Правда, за исключением крайне дурацких прошлых проектов, которые пытались реализовать защиту не на языках со строгой типизацией, а на аппаратном.
  2. Представление указателя в памяти совпадает с целым числом той же длины в битах, что и архитектура. Возможно, что-то вроде интегрального типа одинаково, см. LP64 против LLP64.
  3. Умножение и деление типов данных указателя запрещено только компилятором. Верно.
  4. Все значения указателя могут быть приведены к единственному целому числу. Другими словами, какие архитектуры все еще используют сегменты и смещения? Ничто не использует сегменты и смещения сегодня, но C int часто недостаточно велик, вам может понадобиться long или же long long держать указатель.
  5. Увеличение указателя эквивалентно добавлению sizeof(указанного типа данных) к адресу памяти, сохраняемому указателем. Если p равно int32*, то p+1 равно адресу памяти через 4 байта после p. Да.

Интересно отметить, что каждый процессор архитектуры Intel, т. Е. Каждый PeeCee, содержит сложную единицу сегментации эпической, легендарной, сложности. Тем не менее, он эффективно отключен. Всякий раз, когда загружается операционная система ПК, она устанавливает базы сегментов в 0, а длины сегментов в ~0, обнуляя сегменты и давая модель плоской памяти.

Я хотел бы знать архитектуры, которые нарушают предположения, которые я перечислил ниже.

Я вижу, что Стивен С упомянул машины PERQ, а MSalters упомянул 68000 и PIC.

Я разочарован тем, что никто другой на самом деле не ответил на этот вопрос, назвав какую-то странную и замечательную архитектуру, которая имеет совместимые со стандартами компиляторы C, которые не соответствуют определенным необоснованным предположениям.

sizeof(int *) == sizeof(char *) == sizeof(void *) == sizeof(func_ptr *)?

Не обязательно. Некоторые примеры:

Большинство компиляторов для 8-разрядных процессоров Гарвардской архитектуры - PIC и 8051 и M8C - делают sizeof(int *) == sizeof(char *), но отличаются от sizeof(func_ptr *).

Некоторые из очень маленьких чипов в этих семействах имеют 256 байтов ОЗУ (или меньше), но несколько килобайт PROGMEM (Flash или ROM), поэтому компиляторы часто делают sizeof(int *) == sizeof(char *) равным 1 (a один 8-битный байт), но sizeof(func_ptr *) равен 2 (два 8-битных байта).

Компиляторы для многих более крупных чипов в тех семействах с несколькими килобайтами оперативной памяти и 128 или около того килобайтами PROGMEM делают sizeof(int *) == sizeof(char *) равным 2 (два 8-битных байта), но sizeof(func_ptr *), равный 3 (три 8-битных байта).

Несколько чипов Гарвардской архитектуры могут хранить ровно 2^16 ("64 КБайт") PROGMEM (Flash или ROM) и еще 2^16 ("64 КБайт") ОЗУ + ввод-вывод с отображением памяти. Компиляторы для такого чипа делают sizeof(func_ptr *) всегда равным 2 (два байта); но часто есть способ превратить другие виды указателей sizeof(int *) == sizeof(char *) == sizeof(void *) в трехбайтовый универсальный указатель "long ptr", имеющий дополнительный магический бит, который указывает указывает ли этот указатель на RAM или PROGMEM. (Это тот тип указателя, который вам нужно передать в функцию "print_text_to_the_LCD()", когда вы вызываете эту функцию из множества различных подпрограмм, иногда с адресом переменной строки в буфере, которая может быть где угодно в ОЗУ, а иногда с одним из множества постоянных строк, которые могут быть где угодно в PROGMEM). Такие компиляторы часто имеют специальные ключевые слова ("short" или "near", "long" или "far"), чтобы позволить программистам специально указывать три разных типа указателей на символы в одной и той же программе - строки констант, которым требуется только 2 байта, чтобы указать, где в PROGMEM они расположены, неконстантные строки, которым требуется только 2 байта, чтобы указать, где в ОЗУ они расположены, и вид 3-байтовых указателей, которые принимает print_text_to_the_LCD ().

Большинство компьютеров, построенных в 1950-х и 1960-х годах, используют длину слова 36 бит или длину слова 18 бит, с 18-битной (или меньшей) адресной шиной. Я слышал, что компиляторы C для таких компьютеров часто используют 9-битные байты с sizeof(int *) == sizeof(func_ptr *) = 2, который дает 18 битов, поскольку все целые числа и функции должны быть выровнены по словам; но sizeof(char *) == sizeof(void *) == 4, чтобы воспользоваться специальными инструкциями PDP-10, которые хранят такие указатели в полном 36-битном слове. Это полное 36-разрядное слово включает в себя адрес 18-разрядного слова и еще несколько битов в других 18-разрядных словах, которые (среди прочего) указывают битовую позицию указательного символа в этом слове.

Представление в памяти всех указателей для данной архитектуры одинаково независимо от типа данных, на который указывает?

Не обязательно. Некоторые примеры:

На любой из упомянутых выше архитектур указатели бывают разных размеров. Так как же они могли иметь "одинаковое" представление?

Некоторые компиляторы в некоторых системах используют "дескрипторы" для реализации указателей символов и других видов указателей. Такой дескриптор отличается для указателя, указывающего на первый символ "в" char big_array[4000] "чем для указателя, указывающего на первый" символ "в" char small_array[10] ", которые, возможно, являются различными типами данных, даже когда небольшой массив начинается с точно того же места в памяти, ранее занятой большим массивом. Дескрипторы позволяют таким машинам перехватывать и перехватывать переполнения буфера, которые вызывают такие проблемы на других машинах.

"Low-Fat Pointers", используемые в SAFElite и подобных "мягких процессорах", имеют аналогичную "дополнительную информацию" о размере буфера, на который указывает указатель. Указатели с низким содержанием жира обладают тем же преимуществом, что и перехват переполнения буфера.

Представление указателя в памяти такое же, как целое число той же длины в битах, что и архитектура?

Не обязательно. Некоторые примеры:

В машинах с "теговой архитектурой" каждое слово памяти имеет несколько битов, которые указывают, является ли это слово целым числом, или указателем, или чем-то еще. На таких машинах просмотр битов тега скажет вам, было ли это слово целым числом или указателем.

Я слышал, что у мини-компьютеров Nova есть "бит косвенности" в каждом слове, которое вдохновляло "косвенный многопоточный код". Похоже, что сохранение целого числа очищает этот бит, в то время как указатель устанавливает этот бит.

Умножение и деление типов данных указателя запрещено только компилятором. ПРИМЕЧАНИЕ: Да, я знаю, что это бессмысленно. Что я имею в виду - есть ли аппаратная поддержка, чтобы запретить это неправильное использование?

Да, некоторые устройства не поддерживают такие операции напрямую.

Как уже упоминали другие, команда "умножения" в 68000 и 6809 работает только с (некоторыми) "регистрами данных"; их нельзя напрямую применить к значениям в "адресных регистрах". (Компилятору было бы довольно легко обойти такие ограничения - переместить эти значения из регистра адресов в соответствующий регистр данных, а затем использовать MUL).

Все значения указателя могут быть приведены к одному типу данных?

Да.

Чтобы memcpy() работала правильно, стандарт C требует, чтобы каждое значение указателя любого вида могло быть приведено к указателю void ("void *").

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

Все значения указателя могут быть приведены к одному целому числу? Другими словами, какие архитектуры все еще используют сегменты и смещения?

Я не уверен.

Я подозреваю, что все значения указателя могут быть преобразованы в интегральные типы данных "size_t" и "ptrdiff_t", определенные в " <stddef.h> ".

Увеличение указателя эквивалентно добавлению sizeof (указанного типа данных) к адресу памяти, сохраняемому указателем. Если p равно int32*, то p+1 равно адресу памяти через 4 байта после p.

Неясно, что вы здесь спрашиваете.

Q: Если у меня есть массив какой-то структуры или примитивного типа данных (например, " #include <stdint.h> ... int32_t example_array[1000]; ... "), и я увеличиваю указатель, указывающий на этот массив (например, "int32_t p = &example_array[99]; ... p++; ..."), теперь указатель указывает на следующий следующий элемент этого массив, который является sizeof (указанным типом данных) байтов далее в памяти?

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

Q: Итак, если p - это int32*, то p+1 равно адресу памяти через 4 байта после p?

A: Когда sizeof( int32_t) фактически равен 4, да. В противном случае, например, для некоторых машин, адресуемых по словам, включая некоторые современные DSP, где sizeof( int32_t) может равняться 2 или даже 1, тогда p+1 равно адресу памяти 2 или даже 1 "C байтов" после p.

Q: Так что, если я возьму указатель и приведу его к "int"...

A: Один тип "всего мира - ересь VAX".

Q:... а затем приведите это "int" обратно в указатель...

A: Другой тип "всего мира - ересь VAX".

Q: Так что, если я возьму указатель p, который является указателем на int32_t, и приведу его к некоторому целочисленному типу, который достаточно большой, чтобы содержать указатель, а затем добавлю sizeof( int32_t ) к этому целочисленному типу, а затем приведем этот целочисленный тип обратно к указателю - когда я все это сделаю, результирующий указатель будет равен p+1?

Не обязательно.

Многие DSP и несколько других современных чипов имеют адресную адресацию, а не байтовую обработку, используемую 8-битными чипами.

Некоторые из компиляторов C для таких чипов вмещают по 2 символа в каждое слово, но для хранения int32_t требуется 2 таких слова - поэтому они сообщают, что sizeof( int32_t ) это 4. (Я слышал слухи, что есть компилятор C для 24-битного Motorola 56000, который делает это).

Компилятор должен упорядочить такие вещи, чтобы выполнение "p++" с указателем на int32_t увеличивало указатель на следующее значение int32_t. У компилятора есть несколько способов сделать это.

Один из совместимых со стандартами способов - хранить каждый указатель на int32_t как "адрес собственного слова". Поскольку для хранения одного значения int32_t требуется 2 слова, компилятор C компилирует int32_t * p; ... p++ "в некоторый ассемблер, который увеличивает значение этого указателя на 2. С другой стороны, если это так" int32_t * p; ... int x = (int)p; x += sizeof( int32_t ); p = (int32_t *)x; msgstr "этот компилятор C для 56000, скорее всего, скомпилирует его на ассемблер, который увеличивает значение указателя на 4.

Я больше всего привык к тому, что указатели используются в непрерывном виртуальном пространстве памяти.

Некоторые PIC и 8086 и другие системы имеют несмежную оперативную память - несколько блоков оперативной памяти по адресам, которые "сделали аппаратное обеспечение более простым". С отображаемым в память вводом-выводом или вообще без привязки к промежуткам в адресном пространстве между этими блоками.

Это даже более неловко, чем кажется.

В некоторых случаях - например, с использованием оборудования с битовой полосой, используемого для предотвращения проблем, вызванных чтением-изменением-записью - один и тот же бит в ОЗУ может быть прочитан или записан с использованием 2 или более разных адресов.

РЕДАКТИРОВАТЬ: Не отвечайте на вопросы, когда у вас низкий уровень сахара в крови. Ваш мозг (конечно, мой) не работает, как вы ожидаете.:-(

Незначительный придурок:

p - это int32*, тогда p+1

это неправильно, он должен быть без знака int32, иначе он будет обернут в 2 ГБ.

Интересная странность - я получил это от автора компилятора C для чипа Transputer - он сказал мне, что для этого компилятора NULL был определен как -2 ГБ. Зачем? Потому что у Transputer был диапазон адресов со знаком: от -2 ГБ до +2 ГБ. Вы можете в это поверить? Удивительно, не правда ли?

С тех пор я встречал разных людей, которые говорили мне, что определение NULL как этот не работает. Я согласен, но если вы этого не сделаете, в конечном итоге указатели NULL окажутся в середине диапазона адресов.

Я думаю, что большинство из нас может радоваться, что мы не работаем над Transputers!

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