После прочтения статьи Самая опасная функция в мире С/С++ я счёл полезным углубиться во зло, таящееся в тёмном погребе memset, и написать дополнение, чтобы шире раскрыть суть проблемы.
В языке Си повсеместно используется memset(), таящий в себе множество ловушек. Выдержка из C++ Reference:
void * memset ( void * ptr, int value, size_t num );
Fill block of memory
Sets the first num bytes of the block of memory pointed by ptr to the specified value (interpreted as an unsigned char).
Parameters
ptr — Pointer to the block of memory to fill.
value — Value to be set. The value is passed as an int, but the function fills the block of memory using the unsigned char conversion of this value.
num — Number of bytes to be set to the value. size_t is an unsigned integral type.
Return Value
ptr is returned.
Как уже неоднократно подмечено, есть множество граблей, на которые наступают даже опытные разработчики. Из описанного в статье Andrey2008 краткое обобщение типичных ошибок:
№1. Пытаясь вычислить размер массива, либо структуры, не используйте sizeof() для указателей на массив/структуру, он вернёт вам размер указателя 4 или 8 байт, вместо размера массива/структуры.
№2. Третий аргумент memset() принимает на вход количество байт, а не количество элементов, не учитывая тип данных. Добавлю ещё, например, тип int может занимать как 4, так и 8 байт, в зависимости от архитектуры. На этот случай следует использовать sizeof(int).
№3. Не путайте местами аргументы. Правильная последовательность это указатель, значение, длина в байтах.
№4. Не используйте memset при работе с объектами класса.
Но это лишь вершина айсберга.
Альтернатива memset
memset это низкоуровневая функция, обязывающая разработчика продумывать все особенности архитектуры компьютера и её использование должно быть обосновано. Давайте для начала рассмотрим альтернативу = {0}, вместо memset, говорят это позволяет инициализировать массив или строку на этапе компиляции, что должно повышать быстродействие программы, в отличии от memset (также ZeroMemory), инициализирующих данные во время исполнения. Я решил это проверить.
void doInitialize() { char p0[25] = {0} ; // установит все 25 символов в 0 char p1[25] = "" ; // установит все 25 символов в 0 wchar_t p2[25] = {0} ; // установит 25 символов в 0 wchar_t p3[25] = L"" ; // установит все 25 символов в 0 short p4[62] = {0} // установит 62 значения в 0 int p5[37] = {-1} ; // установит значение первого элемента в -1 unsigned int p6[10] = {89} ; // установит значение первого элемента 89 }
C99 [$6.7.8/21]
If there are fewer initializers in a brace-enclosed list than there are elements or members of an aggregate, or fewer characters in a string literal used to initialize an array of known size than there are elements in the array, the remainder of the aggregate shall be initialized implicitly the same as objects that have static storage duration.
Заодно такая инициализация снимает проблемы №1, №2, №3 с путаницей параметров и размеров буфера. То есть второй и третий аргумент местами мы не перепутаем, размер передавать не надо. Давайте же посмотрим как такой код преобразуют компиляторы. Все компиляторы сразу проверить я не могу, под рукой оказались gcc входящий в android-ndk-r10c, а также gcc в убунту 14.04.
2) gcc version 4.8.2 (Ubuntu 4.8.2-19ubuntu1)
Давайте посмотрим как ведёт себя компилятор на таком куске кода:
void empty_string(){ int i; char p1[25] = {0}; printf("\np1: "); for (i = 0; i < 25; i++) printf("%x,",p1[i]); }
Итак, без оптимизации (-O0) инициализация массива компилируется в такой ассемблерный код (просматриваем бинарники с помощью objdump):
83d8: e3a03000 mov r3, #0 83dc: e50b3024 str r3, [fp, #-36] ; 0x24 83e0: e24b3020 sub r3, fp, #32 83e4: e3a02000 mov r2, #0 83e8: e5832000 str r2, [r3] 83ec: e2833004 add r3, r3, #4 83f0: e3a02000 mov r2, #0 83f4: e5832000 str r2, [r3] 83f8: e2833004 add r3, r3, #4 83fc: e3a02000 mov r2, #0 8400: e5832000 str r2, [r3] 8404: e2833004 add r3, r3, #4 8408: e3a02000 mov r2, #0 840c: e5832000 str r2, [r3] 8410: e2833004 add r3, r3, #4 8414: e3a02000 mov r2, #0 8418: e5832000 str r2, [r3] 841c: e2833004 add r3, r3, #4 8420: e3a02000 mov r2, #0 8424: e5c32000 strb r2, [r3] 8428: e2833001 add r3, r3, #1
400700: 48 c7 45 d0 00 00 00 00 movq $0x0,-0x30(%rbp) 400708: 48 c7 45 d8 00 00 00 00 movq $0x0,-0x28(%rbp) 400710: 48 c7 45 e0 00 00 00 00 movq $0x0,-0x20(%rbp) 400718: c6 45 e8 00 movb $0x0,-0x18(%rbp)
Как и ожидалось, без оптимизации мы получаем run-time код, который будет кушать O(n) процессорного времени (где n длина буфера). Что же сделает компилятор с оптимизацией (-O3) можем видеть ниже.
gcc -O3, 32-bit, ARM
000083ac <empty_string>: 83ac: e59f002c ldr r0, [pc, #44] ; 83e0 <empty_string+0x34> 83b0: e92d4038 push {r3, r4, r5, lr} 83b4: e08f0000 add r0, pc, r0 83b8: ebffffb2 bl 8288 <printf@plt> 83bc: e59f5020 ldr r5, [pc, #32] ; 83e4 <empty_string+0x38> 83c0: e3a04019 mov r4, #25 83c4: e08f5005 add r5, pc, r5 83c8: e1a00005 mov r0, r5 83cc: e3a01000 mov r1, #0 83d0: ebffffac bl 8288 <printf@plt> 83d4: e2544001 subs r4, r4, #1 83d8: 1afffffa bne 83c8 <empty_string+0x1c> 83dc: e8bd8038 pop {r3, r4, r5, pc}
00000000004006d0 <empty_string>: 4006d0: 53 push %rbx 4006d1: be a4 08 40 00 mov $0x4008a4,%esi 4006d6: bf 01 00 00 00 mov $0x1,%edi 4006db: 31 c0 xor %eax,%eax 4006dd: bb 32 00 00 00 mov $0x32,%ebx 4006e2: e8 d9 fd ff ff callq 4004c0 <__printf_chk@plt> 4006e7: 66 0f 1f 84 00 00 00 nopw 0x0(%rax,%rax,1) 4006ee: 00 00 4006f0: 31 d2 xor %edx,%edx 4006f2: 31 c0 xor %eax,%eax 4006f4: be aa 08 40 00 mov $0x4008aa,%esi 4006f9: bf 01 00 00 00 mov $0x1,%edi 4006fe: e8 bd fd ff ff callq 4004c0 <__printf_chk@plt> 400703: 83 eb 01 sub $0x1,%ebx 400706: 75 e8 jne 4006f0 <empty_string+0x20> 400708: 5b pop %rbx 400709: c3 retq
Видим, что кусок кода с обнулением в run-time просто пропал, мы получили обещанную производительность O(1), давайте разберёмся откуда же свои значения берёт printf? Нас интересует вот этот кусочек:
83bc: ldr r5, [pc, #32] 83c0: mov r4, #25 ;// В r4 записываем количество циклов for, это наш счётчик цикла 83c4: add r5, pc, r5 ;// В r5 записываем текст "%x," как константу, в памяти она хранится как 002c7825 83c8: mov r0, r5 ;// r5 неизменно передаётся в r0 на каждой итерации цикла, это первый параметр printf() 83cc: mov r1, #0 ;// записываем константу 0 (вместо фактического p1[i]) как второй параметр printf() 83d0: bl 8288 <printf@plt> 83d4: subs r4, r4, #1 ;// Отнимаем единицу в счётчике цикла 83d8: bne 83c8 <empty_string+0x1c> ;// Если не дошли до 0, то переходим на начало цикла 83c8
То есть компилятор просто выкинул массив, а вместо его значений использует 0, как заложенную на этапе компиляции константу. Хорошо, но что же происходит если мы будем использовать memset? Давайте посмотрим несколько кусочков objdump-а, например, под ARM:
Без оптимизации -O0:
83d8: e24b3024 sub r3, fp, #36 ; 0x24 83dc: e1a00003 mov r0, r3 83e0: e3a01000 mov r1, #0 83e4: e3a02019 mov r2, #25 83e8: ebffffa3 bl 827c <memset@plt>
С оптимизацией -O3:
83c0: e58d3004 str r3, [sp, #4] 83c4: e58d3008 str r3, [sp, #8] 83c8: e58d300c str r3, [sp, #12] 83cc: e58d3010 str r3, [sp, #16] 83d0: e58d3014 str r3, [sp, #20] 83d4: e58d3018 str r3, [sp, #24] 83d8: e5cd301c strb r3, [sp, #28]
400816: ba 19 00 00 00 mov $0x19,%edx 40081b: be 00 00 00 00 mov $0x0,%esi 400820: 48 89 c7 mov %rax,%rdi 400823: e8 a8 fc ff ff callq 4004d0 <memset@plt>
С оптимизацией -O3:
4007f4: 48 c7 04 24 00 00 00 00 movq $0x0,(%rsp) 4007fc: 48 c7 44 24 08 00 00 00 movq $0x0,0x8(%rsp) 400805: 48 c7 44 24 10 00 00 00 movq $0x0,0x10(%rsp) 40080e: c6 44 24 18 00 movb $0x0,0x18(%rsp)
То есть оптимизация просто убирает вызов memset, вставляя его inline. При таких раскладах memset будет всегда работать за O(n) времени, а вот инициализация с помощью = {0} при оптимизации работает за константу, в нашем случае и вовсе не отнимая тактов процессора, нагло выбрасывая сам факт существования массива и подменяя все его элементы нулями. Но давайте посмотрим, всегда ли это так и что будет если мы запишем ненулевое значение после инициализации? Тестовая функция примет вот такой вид:
void empty_string(){ int i; char p1[25] = {0}; p1[0] = 65; printf("\np1: "); for (i = 0; i < 25; i++) printf("%x,",p1[i]); }
После компиляции получаем уже знакомый блок кода
8404: e3a02041 mov r2, #65 ; 0x41 8408: e08f0000 add r0, pc, r0 840c: e58d3004 str r3, [sp, #4] 8410: e58d3008 str r3, [sp, #8] 8414: e58d300c str r3, [sp, #12] 8418: e58d3010 str r3, [sp, #16] 841c: e58d3014 str r3, [sp, #20] 8420: e58d3018 str r3, [sp, #24] 8424: e5cd301c strb r3, [sp, #28] 8428: e5cd2004 strb r2, [sp, #4]
4006f8: 48 c7 04 24 00 00 00 movq $0x0,(%rsp) 4006ff: 00 400700: 48 c7 44 24 08 00 00 movq $0x0,0x8(%rsp) 400707: 00 00 400709: 48 c7 44 24 10 00 00 movq $0x0,0x10(%rsp) 400710: 00 00 400712: c6 44 24 18 00 movb $0x0,0x18(%rsp) 400717: c6 04 24 41 movb $0x41,(%rsp)
И выглядит это так, как будто компилятор нам вставил оптимизированную версию memset. А давайте посмотрим что будет, если размер массива значительно вырастет? Скажем, не 25 байт, а 25 килобайт!
83fc: e24ddc61 sub sp, sp, #24832 ; 0x6100 8400: e24dd0a8 sub sp, sp, #168 ; 0xa8 8404: e3a01000 mov r1, #0 8408: e59f2054 ldr r2, [pc, #84] ; 8464 <empty_string+0x6c> 840c: e1a0000d mov r0, sp 8410: ebffff99 bl 827c <memset@plt>
400720: 55 push %rbp 400721: ba a8 61 00 00 mov $0x61a8,%edx 400726: 31 f6 xor %esi,%esi 400728: 53 push %rbx 400729: 48 81 ec b8 61 00 00 sub $0x61b8,%rsp 400730: 48 89 e7 mov %rsp,%rdi 400733: 48 8d ac 24 a8 61 00 lea 0x61a8(%rsp),%rbp 40073a: 00 40073b: 48 89 e3 mov %rsp,%rbx 40073e: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 400745: 00 00 400747: 48 89 84 24 a8 61 00 mov %rax,0x61a8(%rsp) 40074e: 00 40074f: 31 c0 xor %eax,%eax 400751: e8 8a fd ff ff callq 4004e0 <memset@plt> 400756: be 54 09 40 00 mov $0x400954,%esi 40075b: bf 01 00 00 00 mov $0x1,%edi 400760: 31 c0 xor %eax,%eax 400762: c6 04 24 41 movb $0x41,(%rsp) 400766: e8 a5 fd ff ff callq 400510 <__printf_chk@plt> 40076b: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1) 400770: 0f be 13 movsbl (%rbx),%edx 400773: 31 c0 xor %eax,%eax 400775: be 5a 09 40 00 mov $0x40095a,%esi 40077a: bf 01 00 00 00 mov $0x1,%edi 40077f: 48 83 c3 01 add $0x1,%rbx 400783: e8 88 fd ff ff callq 400510 <__printf_chk@plt> 400788: 48 39 eb cmp %rbp,%rbx 40078b: 75 e3 jne 400770 <empty1_string+0x50> 40078d: 48 8b 84 24 a8 61 00 mov 0x61a8(%rsp),%rax 400794: 00 400795: 64 48 33 04 25 28 00 xor %fs:0x28,%rax 40079c: 00 00 40079e: 75 0a jne 4007aa <empty1_string+0x8a> 4007a0: 48 81 c4 b8 61 00 00 add $0x61b8,%rsp 4007a7: 5b pop %rbx 4007a8: 5d pop %rbp 4007a9: c3 retq
Надо же!
Строка = {0} переходит на сторону тьмы, memset ликует!
Однако, не будем забываться, всё же проблему с параметрами мы решили, перепутать аргументы теперь не удастся.
Инициализация строки
Также не будет лишним рассмотреть вариант инициализации массива = "". В языке Си используются нуль-терминированные строки, то есть первый символ с байтовым значением 0x00 означает конец строки. Поэтому для инициализации строки нет смысла обнулять все элементы, достаточно лишь обнулить первый. Вот некоторые способы инициализировать пустую строку:
void doInitializeCString() { char p0[25] = {0} ; // установит все символы в 0 char p1[25] = "" ; // установит все символы в 0 char p2[25] ; p2[0] = 0 ; // установит первый символ в 0 char p3[25] ; memset(p3, 0, sizeof(p3)) ; // установит 25 символов в 0 char p4[25] ; strcpy(p4, "") ; // установит первый символ в 0 char *p5 = (char *) calloc(25, sizeof(char)) ; // установит все символы в 0 }
Самый надёжный способ, как будет работать инициализация через = "" снова разобрать objdump. Без оптимизации ничего особенного мы не увидим, там всё аналогично = {0}, рассмотрим сразу с опцией -O3. Итак компилируем под ARM
void empty_string(){ int i; char p1[25] = ""; printf("\np1: "); for (i = 0; i < 25; i++) printf("%x,",p1[i]); }
И, внезапно, получаем обнуление всех элементов массива.
83c0: e58d3004 str r3, [sp, #4] 83c4: e58d3008 str r3, [sp, #8] 83c8: e58d300c str r3, [sp, #12] 83cc: e58d3010 str r3, [sp, #16] 83d0: e58d3014 str r3, [sp, #20] 83d4: e58d3018 str r3, [sp, #24] 83d8: e5cd301c strb r3, [sp, #28]
400768: 48 c7 04 24 00 00 00 00 movq $0x0,(%rsp) 400770: 48 c7 44 24 08 00 00 00 movq $0x0,0x8(%rsp) 400779: 48 c7 44 24 10 00 00 00 movq $0x0,0x10(%rsp) 400782: c6 44 24 18 00 movb $0x0,0x18(%rsp)
Та ну ладно! Зачем в нуль-терминированной строке обнулять все неиспользованные символы?! Достаточно же обнулить один единственный байт. Хм, а если там будет 25 тысяч байт, что оно сделает? А вот что:
8474: e24ddc61 sub sp, sp, #24832 ; 0x6100 8478: e24dd0a8 sub sp, sp, #168 ; 0xa8 847c: e3a0c000 mov ip, #0 8480: e28d3f6a add r3, sp, #424 ; 0x1a8 8484: e1a0100c mov r1, ip 8488: e59f204c ldr r2, [pc, #76] ; 84dc <empty_string+0x6c> 848c: e28d0004 add r0, sp, #4 8490: e503c1a8 str ip, [r3, #-424] ; 0x1a8 8494: ebffff78 bl 827c <memset@plt>
00000000004007b0 <empty_string>: 4007b0: 55 push %rbp 4007b1: ba a0 61 00 00 mov $0x61a0,%edx 4007b6: 31 f6 xor %esi,%esi 4007b8: 53 push %rbx 4007b9: 48 81 ec b8 61 00 00 sub $0x61b8,%rsp 4007c0: 48 8d 7c 24 08 lea 0x8(%rsp),%rdi 4007c5: 48 8d ac 24 a8 61 00 lea 0x61a8(%rsp),%rbp 4007cc: 00 4007cd: 48 c7 04 24 00 00 00 movq $0x0,(%rsp) 4007d4: 00 4007d5: 64 48 8b 04 25 28 00 mov %fs:0x28,%rax 4007dc: 00 00 4007de: 48 89 84 24 a8 61 00 mov %rax,0x61a8(%rsp) 4007e5: 00 4007e6: 31 c0 xor %eax,%eax 4007e8: 48 89 e3 mov %rsp,%rbx 4007eb: e8 f0 fc ff ff callq 4004e0 <memset@plt> 4007f0: be 54 09 40 00 mov $0x400954,%esi 4007f5: bf 01 00 00 00 mov $0x1,%edi 4007fa: 31 c0 xor %eax,%eax 4007fc: e8 0f fd ff ff callq 400510 <__printf_chk@plt> 400801: 0f 1f 80 00 00 00 00 nopl 0x0(%rax) 400808: 0f be 13 movsbl (%rbx),%edx 40080b: 31 c0 xor %eax,%eax 40080d: be 5a 09 40 00 mov $0x40095a,%esi 400812: bf 01 00 00 00 mov $0x1,%edi 400817: 48 83 c3 01 add $0x1,%rbx 40081b: e8 f0 fc ff ff callq 400510 <__printf_chk@plt> 400820: 48 39 eb cmp %rbp,%rbx 400823: 75 e3 jne 400808 <empty_string+0x58> 400825: 48 8b 84 24 a8 61 00 mov 0x61a8(%rsp),%rax 40082c: 00 40082d: 64 48 33 04 25 28 00 xor %fs:0x28,%rax 400834: 00 00 400836: 75 0a jne 400842 <empty_string+0x92> 400838: 48 81 c4 b8 61 00 00 add $0x61b8,%rsp 40083f: 5b pop %rbx 400840: 5d pop %rbp 400841: c3 retq
Похоже, тёмный memset преследует нас. Если вы всё ещё хотите сражаться против тьмы, то стоит упомянуть какие ловушки вас поджидают.
memset может инициализировать числа неправильными значениями
Если вы хотите заполнить массив целых чисел ненулевыми значениями, ознакомьтесь с побайтовым заполнением данных.
void doInitializeToMistakenValues() { char pChar[25] ; unsigned char pUChar[25] ; short pShort[25] ; unsigned short pUShort[25] ; int pInt[25] ; unsigned int pUInt[25] ; // Значения 2-байтовых и 4-байтовых элементов будут отличны от единицы memset(pChar, 1, sizeof(pChar)) ; // 1 memset(pUChar, 1, sizeof(pUChar)) ; // 1 memset(pShort, 1, sizeof(pShort)) ; // 257 memset(pUShort, 1, sizeof(pUShort)) ; // 257 memset(pInt, 1, sizeof(pInt)) ; // 16843009 memset(pUInt, 1, sizeof(pUInt)) ; // 16843009 // Значения unsigned массивов заполнится байтами 0xFF memset(pChar, -1, sizeof(pChar)) ; // -1 memset(pUChar, -1, sizeof(pUChar)) ; // 255 memset(pShort, -1, sizeof(pShort)) ; // -1 memset(pUShort, -1, sizeof(pUShort)) ; // 65535 memset(pInt, -1, sizeof(pInt)) ; // -1 memset(pUInt, -1, sizeof(pUInt)) ; // 4294967295 }
Рассмотрим как это получается. Вот имеем скажем массив int, передаём вторым параметром единицу, что происходит?
А вот что:
0x01010101 — в шестнадцатеричной записи каждый байт будет заполнен единицей, а правильное значение
0x00000001 будет невозможно задать функцией memset. Но на самом деле это не баг, это фича.
Вот только незнание этих фич приводит к непредсказуемым ошибкам.
memset может установить невалидное значение
Если в элементы double установить байты -1, мы получим значение Not-A-Number (NaN), а в последствии последующих вычислений, каждая операция со значением NaN будет возращать NaN, таким образом нарушая всю цепочку вычислений.
Таким же образом устанавливать -1 в тип bool некорректно и он формально не будет ни true, ни false. Хотя в большинстве случаев он будет вести себя как true. В большинстве случаев…
И последнее, memset предназначен только для работы с простыми структурами данных. Никогда не используйте memset с управляемыми структурами данных, эта функция предназначена только для низкоуровневых операций.
В статье использованы материалы memset is evil.
Также читайте про уязвимости функции printf.
ссылка на оригинал статьи http://habrahabr.ru/post/272269/
Добавить комментарий