Структуры, как и массивы, являются контейнерами данных. Каждый раз, когда вы работаете с контейнером, вы будете размещать его данные в непрерывном блоке памяти. Сам контейнер идентифицируется по его начальному адресу, и каждый раз, когда вы работаете с ним, ваша программа должна будет выполнять арифметику указателя низкого уровня c с помощью специальных инструкций, чтобы применить смещение, чтобы получить от начального адреса до желаемого поле (или элемент в случае массивов). Единственное, что нужно знать компилятору для работы со структурой, это (примерно):
- Его начальный адрес в памяти.
- Смещение каждого поля.
- Размер каждого поля.
Компилятор может оптимизировать код, работающий со структурами, таким же образом, если структура передана как указатель или нет , и мы увидим как в данный момент. Что отличается, тем не менее, это как структура передается каждой функции.
Сначала позвольте мне прояснить одну вещь: квалификатор const
не полезен, чтобы понять разницу между передачей структуры как указатель или по значению. Это просто говорит компилятору, что внутри функции значение самого параметра не будет изменено. * Разница в производительности между передачей в качестве значения или в качестве указателя в общем случае const
не затрагивается. Ключевое слово const
становится полезным только для других видов оптимизации, а не для этой.
Основное различие между этими двумя сигнатурами:
void first(const struct mystruct x);
void second(struct mystruct *x);
состоит в том, что первая функция ожидает вся структура для передачи в качестве параметра, что означает копирование всей структуры в стеке непосредственно перед вызовом функции. Вторая функция, однако, нуждается только в указателе на структуру, и поэтому аргумент может быть передан как одно значение в стеке или в регистре, как это обычно делается в x86-64.
Теперь, чтобы лучше понять, что происходит, давайте проанализируем следующую программу:
#include <stdio.h>
struct mystruct {
unsigned a, b, c, d, e, f, g, h, i, j, k;
};
unsigned long __attribute__ ((noinline)) first(const struct mystruct x) {
unsigned long total = x.a;
total += x.b;
total += x.c;
total += x.d;
total += x.e;
total += x.f;
total += x.g;
total += x.h;
total += x.i;
total += x.j;
total += x.k;
return total;
}
unsigned long __attribute__ ((noinline)) second(struct mystruct *x) {
unsigned long total = x->a;
total += x->b;
total += x->c;
total += x->d;
total += x->e;
total += x->f;
total += x->g;
total += x->h;
total += x->i;
total += x->j;
total += x->k;
return total;
}
int main (void) {
struct mystruct x = {0};
scanf("%u", &x.a);
unsigned long v = first(x);
printf("%lu\n", v);
v = second(&x);
printf("%lu\n", v);
return 0;
}
* * * * * * * * * * * * * * * __attribute__ ((noinline))
просто для того, чтобы избежать автоматического c встраивания функции, которая для целей тестирования очень проста и поэтому, вероятно, будет встроен с -O3
.
Давайте теперь скомпилируем и разберем результат с помощью objdump
.
Без оптимизации
Давайте сначала скомпилируем без оптимизации и посмотрим, что произойдет:
Вот как main()
вызывает first()
:
86a: 48 89 e0 mov rax,rsp
86d: 48 8b 55 c0 mov rdx,QWORD PTR [rbp-0x40]
871: 48 89 10 mov QWORD PTR [rax],rdx
874: 48 8b 55 c8 mov rdx,QWORD PTR [rbp-0x38]
878: 48 89 50 08 mov QWORD PTR [rax+0x8],rdx
87c: 48 8b 55 d0 mov rdx,QWORD PTR [rbp-0x30]
880: 48 89 50 10 mov QWORD PTR [rax+0x10],rdx
884: 48 8b 55 d8 mov rdx,QWORD PTR [rbp-0x28]
888: 48 89 50 18 mov QWORD PTR [rax+0x18],rdx
88c: 48 8b 55 e0 mov rdx,QWORD PTR [rbp-0x20]
890: 48 89 50 20 mov QWORD PTR [rax+0x20],rdx
894: 8b 55 e8 mov edx,DWORD PTR [rbp-0x18]
897: 89 50 28 mov DWORD PTR [rax+0x28],edx
89a: e8 81 fe ff ff call 720 <first>
И это сама функция:
0000000000000720 <first>:
720: 55 push rbp
721: 48 89 e5 mov rbp,rsp
724: 8b 45 10 mov eax,DWORD PTR [rbp+0x10]
727: 89 c0 mov eax,eax
729: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
72d: 8b 45 14 mov eax,DWORD PTR [rbp+0x14]
730: 89 c0 mov eax,eax
732: 48 01 45 f8 add QWORD PTR [rbp-0x8],rax
736: 8b 45 18 mov eax,DWORD PTR [rbp+0x18]
739: 89 c0 mov eax,eax
... same stuff happening over and over ...
783: 48 01 45 f8 add QWORD PTR [rbp-0x8],rax
787: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
78b: 5d pop rbp
78c: c3 ret
Совершенно очевидно, что вся структура копируется в стек перед вызовом функции .
Затем функция принимает каждое значение в структуре, просматривая каждое значение, содержащееся в структура в стеке каждый раз (DWORD PTR [rbp + offset]
).
Вот как main()
вызывает second()
:
8bf: 48 8d 45 c0 lea rax,[rbp-0x40]
8c3: 48 89 c7 mov rdi,rax
8c6: e8 c2 fe ff ff call 78d <second>
И это функция само по себе:
000000000000078d <second>:
78d: 55 push rbp
78e: 48 89 e5 mov rbp,rsp
791: 48 89 7d e8 mov QWORD PTR [rbp-0x18],rdi
795: 48 8b 45 e8 mov rax,QWORD PTR [rbp-0x18]
799: 8b 00 mov eax,DWORD PTR [rax]
79b: 89 c0 mov eax,eax
79d: 48 89 45 f8 mov QWORD PTR [rbp-0x8],rax
7a1: 48 8b 45 e8 mov rax,QWORD PTR [rbp-0x18]
7a5: 8b 40 04 mov eax,DWORD PTR [rax+0x4]
7a8: 89 c0 mov eax,eax
... same stuff happening over and over ...
81f: 48 01 45 f8 add QWORD PTR [rbp-0x8],rax
823: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
827: 5d pop rbp
828: c3 ret
Вы можете видеть, что аргумент передается как указатель, а не копируется в стек, что является всего лишь двумя очень простыми инструкциями (lea
+ mov
). Однако , поскольку теперь функция должна работать с указателем с помощью оператора ->
, мы видим, что каждый раз, когда необходимо получить доступ к значению в структуре, необходимо разыменовывать память two раз вместо одного (сначала получить указатель на структуру из стека, затем получить значение с указанным смещением в структуре).
It может показаться, что между этими двумя функциями нет реальной разницы, поскольку линейное число инструкций (линейное в терминах членов структуры), которое требовалось для загрузки структуры в стек в первом случае, все еще требуется для разыменования указатель в другой раз во втором случае.
Мы говорим об оптимизации, и нет смысла не оптимизировать код. Посмотрим, что произойдет, если мы это сделаем.
С оптимизацией
На самом деле, при работе с struct
нам не важно, где он находится в памяти (стек, куча, сегмент данных и т. Д.). Пока мы знаем, с чего все начинается, все сводится к применению одной и той же простой арифметики указателя c для доступа к полям. Это всегда необходимо выполнять независимо от того, где находится структура или от того, была ли она динамически распределена или нет.
Если мы оптимизируем код выше с помощью -O3
, мы теперь видим следующее :
Вот как main()
вызывает first()
:
61a: 48 83 ec 30 sub rsp,0x30
61e: 48 8b 44 24 30 mov rax,QWORD PTR [rsp+0x30]
623: 48 89 04 24 mov QWORD PTR [rsp],rax
627: 48 8b 44 24 38 mov rax,QWORD PTR [rsp+0x38]
62c: 48 89 44 24 08 mov QWORD PTR [rsp+0x8],rax
631: 48 8b 44 24 40 mov rax,QWORD PTR [rsp+0x40]
636: 48 89 44 24 10 mov QWORD PTR [rsp+0x10],rax
63b: 48 8b 44 24 48 mov rax,QWORD PTR [rsp+0x48]
640: 48 89 44 24 18 mov QWORD PTR [rsp+0x18],rax
645: 48 8b 44 24 50 mov rax,QWORD PTR [rsp+0x50]
64a: 48 89 44 24 20 mov QWORD PTR [rsp+0x20],rax
64f: 8b 44 24 58 mov eax,DWORD PTR [rsp+0x58]
653: 89 44 24 28 mov DWORD PTR [rsp+0x28],eax
657: e8 74 01 00 00 call 7d0 <first>
И это сама функция:
00000000000007d0 <first>:
7d0: 8b 44 24 0c mov eax,DWORD PTR [rsp+0xc]
7d4: 8b 54 24 08 mov edx,DWORD PTR [rsp+0x8]
7d8: 48 01 c2 add rdx,rax
7db: 8b 44 24 10 mov eax,DWORD PTR [rsp+0x10]
7df: 48 01 d0 add rax,rdx
7e2: 8b 54 24 14 mov edx,DWORD PTR [rsp+0x14]
7e6: 48 01 d0 add rax,rdx
7e9: 8b 54 24 18 mov edx,DWORD PTR [rsp+0x18]
7ed: 48 01 c2 add rdx,rax
7f0: 8b 44 24 1c mov eax,DWORD PTR [rsp+0x1c]
7f4: 48 01 c2 add rdx,rax
7f7: 8b 44 24 20 mov eax,DWORD PTR [rsp+0x20]
7fb: 48 01 d0 add rax,rdx
7fe: 8b 54 24 24 mov edx,DWORD PTR [rsp+0x24]
802: 48 01 d0 add rax,rdx
805: 8b 54 24 28 mov edx,DWORD PTR [rsp+0x28]
809: 48 01 c2 add rdx,rax
80c: 8b 44 24 2c mov eax,DWORD PTR [rsp+0x2c]
810: 48 01 c2 add rdx,rax
813: 8b 44 24 30 mov eax,DWORD PTR [rsp+0x30]
817: 48 01 d0 add rax,rdx
81a: c3 ret
Вот как main()
вызывает second()
:
671: 48 89 df mov rdi,rbx
674: e8 a7 01 00 00 call 820 <second>
И это сама функция:
0000000000000820 <second>:
820: 8b 47 04 mov eax,DWORD PTR [rdi+0x4]
823: 8b 17 mov edx,DWORD PTR [rdi]
825: 48 01 c2 add rdx,rax
828: 8b 47 08 mov eax,DWORD PTR [rdi+0x8]
82b: 48 01 d0 add rax,rdx
82e: 8b 57 0c mov edx,DWORD PTR [rdi+0xc]
831: 48 01 d0 add rax,rdx
834: 8b 57 10 mov edx,DWORD PTR [rdi+0x10]
837: 48 01 c2 add rdx,rax
83a: 8b 47 14 mov eax,DWORD PTR [rdi+0x14]
83d: 48 01 c2 add rdx,rax
840: 8b 47 18 mov eax,DWORD PTR [rdi+0x18]
843: 48 01 d0 add rax,rdx
846: 8b 57 1c mov edx,DWORD PTR [rdi+0x1c]
849: 48 01 d0 add rax,rdx
84c: 8b 57 20 mov edx,DWORD PTR [rdi+0x20]
84f: 48 01 c2 add rdx,rax
852: 8b 47 24 mov eax,DWORD PTR [rdi+0x24]
855: 48 01 c2 add rdx,rax
858: 8b 47 28 mov eax,DWORD PTR [rdi+0x28]
85b: 48 01 d0 add rax,rdx
85e: c3 ret
Теперь должно быть ясно, какой код лучше. Компилятор успешно определил, что все, что ему нужно в обоих случаях, это знать, где находится начало структуры, а затем он может просто применить ту же простую математику, чтобы определить положение каждого поля. Независимо от того, находится адрес в стеке или где-то еще, это не имеет значения.
Фактически, в случае first()
мы видим, что все поля доступны через [rsp + offset]
, что означает, что некоторый адрес в стеке само по себе (rsp
) используется для вычисления положения полей, в то время как в случае second()
мы видим [rdi + offset]
, что означает, что вместо этого используется адрес, переданный в качестве параметра (в rdi
). Хотя смещения все те же.
Так в чем же разница между двумя функциями? С точки зрения самого кода функции, в основном, нет. С точки зрения передачи параметров, функция first()
все еще нуждается в структуре, переданной по значению, и поэтому даже при включенной оптимизации всю структуру все еще необходимо скопировать в стек, поэтому мы видим, что first()
функция намного тяжелее и добавляет много кода в вызывающую сторону .
Как я уже говорил, компилятор может оптимизировать код, работающий со структурами, таким же образом, если структура передается как указатель или нет. Однако, как мы только что видели, то, как передается структура, делает большую разницу в вызывающей стороне.
Можно утверждать, что квалификатор const
для функции first()
может звонить звонок для компилятора и чтобы он понял, что на самом деле нет необходимости копировать данные в стек, и вызывающая сторона может просто передать указатель. Однако компилятор должен строго придерживаться соглашения о вызовах, продиктованного ABI для данной сигнатуры, вместо того, чтобы изо всех сил оптимизировать код. В конце концов, это не ошибка компилятора, а ошибка программиста.
Итак, чтобы ответить на ваш вопрос:
Только с точки зрения производительности (даже если в моих примерах это ничтожно мало), какой способ работы предпочтительнее?
Определенно предпочтительным способом является передача указателя, а не самого struct
.