Насколько крепка дружба между Java и С внутри Dalvik VM?

В данной статье попытался очень подробно описать свои шаги при исследовании кода андроида и его выполнения в Dalvik VM. Мне было очень интересно узнать ответы, на вопросы:

  • Как выглядит код, генерируемый С? (с позиции ARM)
  • Как выглядит код, генерируемый Java?
  • Как и где происходит выполнение кода?

Поэтому данная статья разбита на 3 части.

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


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

Вступление


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

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

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

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

Кому будет интересна статья

Только искателям приключений.

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

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

Первоначальная цель

Моя основная задача сравнить код на С:
    int sum(int a, int b) {
        return a + b;
    }


C таким же кодом на Java:
    public class Summator {
        int sum(int a, int b) {
            return a + b;
        }

        static int staticSum(int a, int b) {
            return a + b;
        }
    }


    public class NativeSummator {
        native int sum(int a, int b);
        static native int staticSum(int a, int b);
    }


Свои предположения есть, но очень хочется посмотреть как действительно оно происходит.

Подготовительные знания

Я здесь напомню, как примерно выглядит интеграция С кода в Java, через JNI.

Для того, чтобы использовать внешнюю (нативную) функцию в коде Java, используется ключевое слово native:

package com.m039.study;
public class Summator {
        native int sum(int a, int b);
}


При этом код на С должен выглядеть:

int
Java_com_m039_study_Summator_sum(JNIEnv* env, jobject thiz, int a, int b) {
        return a + b;
}


Для того, чтобы Java увидела функцию она должна быть составлена по определенным правилам (дано по памяти):
  • название начинается с Java
  • содержит путь к: модулю + название класса + имя функции
  • в функцую передаются два дополнительных аргумента: указатель на окружение и структура на объект или на класс (если метод static)


Для компиляции используется команда ndk-build. Эта команда ожидает, что файлы на С (или С++) находятся в каталоге ./jni. В каталоге обязан находится файл Android.mk и может быть Application.mk.

После компиляции будет создана библиотека libNAME.so в директории ./libs/armebi, где armebi может отличаться.

Вам придется обратиться к другому документу, если вам нужно более детальные шаги. Можете посмотреть в каталог документации и примеров, прилагаемых к NDK.

Часть I. Как выглядит код, генерируемый С?


Вопрос: Что именно делает код на С?

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

Чтобы разобраться в этом необходимо понять как код на С выглядит в ассемблерном(сыром) виде.

Дизассемблируем

Основной вопрос это взаимодействие С и Java, исходя из этого выбрал 5 примеров:

1. Стандартный:
 
    int sum(int a, int b) {
        return a + b;
    }


2. Биндинг через JNI (не static):
    int
    Java_com_m039_study_Summator_sum(JNIEnv* env,
                                     jobject thiz,
                                     int a,
                                     int b) {
        return a + b;
    }


3. Биндинг через JNI (static):
    int
    Java_com_m039_study_StaticSummator_sum(JNIEnv* env,
                                           jclass thiz,
                                           int a,
                                           int b) {
        return a + b;
    }


4. Биндинг через JNI (не static, через функцию):
    int
    Java_com_m039_study_Summator_sum(JNIEnv* env,
                                     jobject thiz,
                                     int a,
                                     int b) {
        return sum(a, b);
    }


5. Биндинг через JNI (staic, через функцию):
    int
    Java_com_m039_study_StaticSummator_sum(JNIEnv* env,
                                           jclass thiz,
                                           int a,
                                           int b) {
        return sum(a, b);
    }


Немного подумаем. Как мне кажется 4 и 5 почти похожи, если не сказать полностью. А 1-3 должны отличаться передаваемыми параметрами. Вот это и хотелось бы проверить.

Система сборки

Система сборки андроид приложений крайне упрощена, а для таких целей, как проверка компилируемого кода для андроида необходимо знать флаги компиляции. В особенности флаг оптимизации.

В файле Application.mk можно изменить переменную APP_OPTIM и присвоить ей одно из двух значений: release или debug.

Т.к. цель данной статьи сравнить интеграцию двух языков, а не разобраться хорошо в одном, то для простоты рассуждений буду использовать значение 'debug'. Это возможно даст лучшее понимание и меньше шансов на ошибку (что даст больше шансов написать эту статью), но всё равно, потом необходимо посмотреть насколько код в режими 'release' отличается.

Теперь немного поподробнее.

Файл APPLICATION-MK.html описывает, что значит флаг APP_OPTIM, там также указано, что можно изменить в таге application аттрибут на android:debuggable="true" и переменной APP_OPTIM присвоится значение 'debug'. Если аттрибуту присвоить значение «false», то APP_OPTIM присвоится 'release'.

Поэтому в дальнейшем предполагается что в AndroidManifest.xml установлен аттрибут в android:debuggable в «true».

И нельзя упустить тот момент, какие значения будут переданы компилятору, при использовании переменной APP_OPTIM, это можно посмотреть ниже, в том же файле:

ifeq ($(APP_OPTIM),debug)
   APP_CFLAGS := -O0 -g $(APP_CFLAGS)
 else
   APP_CFLAGS := -O2 -DNDEBUG -g $(APP_CFLAGS)
 endif


Теперь приступ к рассмотру всех 5 вариантов.

В режиме 'debug'

Примечание: все листинги были получены командой arm-linux-androideabi-objdump -d "путь к файлу"

Вариант 1

00000804 <sum>:
 804:   b082        sub sp, #8
 806:   9001        str r0, [sp, #4]
 808:   9100        str r1, [sp, #0]
 80a:   9a01        ldr r2, [sp, #4]
 80c:   9b00        ldr r3, [sp, #0]
 80e:   18d3        adds    r3, r2, r3
 810:   1c18        adds    r0, r3, #0
 812:   b002        add sp, #8
 814:   4770        bx  lr
 816:   46c0        nop     (mov r8, r8)


В данном листинге много лишнего, а всё из-за соглашения вызова, а конкретнее ARM calling convention.

Перевожу на данный пример:
— 04: резервирует память под 2 локальные переменные.
— 06-08: сохраняет аргументы, переданные в функцию.
— 0a-0c: эти же аргументы передаются в другие регистры r2 и r3, соответсвенно.
— 0e: равносильно r3 = r2 + r3
— 10: сохранения результата в регистр r0
— 12: возвращаем указатель стека в начальное состояние
— 14: возвращает туда, откуда пришли
— 16: выравнивание функции в файле с помощью комманды nop

Дополнение: мне было не понятно 2 момента: суффикс s и комманда bx. Первое означает, что при выполнении также будут обновлен флаг состояния. Второе является thumb коммандой, которая равносильна комманде bl.

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

В этом листинге надо отметить только 2 момента, как передаются аргументы в функцию и как выполняется основная часть.

Аргументы передаются через регистры, основная часть содержить всего лишь одну команду. Отсюда можно было сделать вывов, что код adds r0, r1, r2; bx lr имеет право на существование.

Но повторюсь, нельзя утверждать, что такой код будет в готовом приложении (если только, кто-то не надумает выпускать 'debug' версию на рынок). И пока есть предположение, что в Java всё еще хуже и его хочется проверить. Поэтому надо быстренько пройтись по оставшимся листингам.

Вариант 2

000007ec <Java_com_m039_study_Summator_sum>:
 7ec:   b084        sub sp, #16
 7ee:   9003        str r0, [sp, #12]
 7f0:   9102        str r1, [sp, #8]
 7f2:   9201        str r2, [sp, #4]
 7f4:   9300        str r3, [sp, #0]
 7f6:   9a01        ldr r2, [sp, #4]
 7f8:   9b00        ldr r3, [sp, #0]
 7fa:   18d3        adds    r3, r2, r3
 7fc:   1c18        adds    r0, r3, #0
 7fe:   b004        add sp, #16
 800:   4770        bx  lr
 802:   46c0        nop         (mov r8, r8)


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

Примечание: пока подтверждается предположение, что использовать много JNI функций (которым передаются лишние аргументы), хуже чем использовать простые функции (которым просто передаются меньше аргументов). (Сейчас, когда переписываю эту статью, это утверждение звучит очень наивно)

Вариант 3

00000850 <Java_com_m039_study_StaticSummator_sum>:
 850:   b084            sub     sp, #16
 852:   9003            str     r0, [sp, #12]
 854:   9102            str     r1, [sp, #8]
 856:   9201            str     r2, [sp, #4]
 858:   9300            str     r3, [sp, #0]
 85a:   9a01            ldr     r2, [sp, #4]
 85c:   9b00            ldr     r3, [sp, #0]
 85e:   18d3            adds    r3, r2, r3
 860:   1c18            adds    r0, r3, #0
 862:   b004            add     sp, #16
 864:   4770            bx      lr
 866:   46c0            nop                     (mov r8, r8)


Предположение оправдалось, код идентичен 2 варианту.

Вариант 4

000008e0 <Java_com_m039_study_Summator_sum>:
 8e0:   b500            push    {lr}
 8e2:   b085            sub     sp, #20
 8e4:   9003            str     r0, [sp, #12]
 8e6:   9102            str     r1, [sp, #8]
 8e8:   9201            str     r2, [sp, #4]
 8ea:   9300            str     r3, [sp, #0]
 8ec:   9a01            ldr     r2, [sp, #4]
 8ee:   9b00            ldr     r3, [sp, #0]
 8f0:   1c10            adds    r0, r2, #0
 8f2:   1c19            adds    r1, r3, #0
 8f4:   f7ff ffde       bl      8b4 <sum>
 8f8:   1c03            adds    r3, r0, #0
 8fa:   1c18            adds    r0, r3, #0
 8fc:   b005            add     sp, #20
 8fe:   bd00            pop     {pc}


Зачем всё-таки нужно рассматривать этот вариант? На тот случай, если показалось, что писать лучше всё в JNI функциях. Тогда как на самом деле, лучше использовать JNI как обертку для более сложных конструкций. А уж в маленьких С функций можно делать всё, что захочется. (Замечание: после написания этой статьи мне это утверждения уже кажется очевидным)

Данный код немного сложнее:
— e0: сохраняет адрес возврата
— e2: резервирует память под локальные переменые
— e4-ea: сохраняет аргументы в локальные переменные
— ec-ee: берет значение локальный переменных
— f0-f2: подготавливает значение регистров к вызову функции
— f4: сам вызов функции
— f8-fa: сохраняет результат в регистр, а потом его записывает в регистр для возвращающего значения
— fc: возвращает указатель стека в начальное положение
— fe: переходит туда, откуда вызвали

Строчка 'e0', используется для сохранения адреса возврата с учетом того, что регистр lr будут переписан через инструкцию bl.

Как можно заметить, оочень много лишнего кода. Предположу, что такой код имеет право на существование:
push    {lr}
sub     sp, #20
adds    r0, r2, #0
adds    r1, r3, #0
bl      8b4 <sum>
add     sp, #20
pop     {pc}


Вариант 5

Думаю его рассматривать не стоит.

В режиме 'release'

Очень хочется проверить свои догадки по поводу того какой код будет в оптимизированном варианте.
Вариант 1

0000894 <sum>:
 894:   1808            adds    r0, r1, r0
 896:   4770            bx      lr

Вариант 2

00000890 <Java_com_m039_study_Summator_sum>:
 890:   1898            adds    r0, r3, r2
 892:   4770            bx      lr

Вариант 3

00000898 <Java_com_m039_study_StaticSummator_SUM>:
 898:   1898            adds    r0, r3, r2
 89a:   4770            bx      lr

Вариант 4

0000089c <Java_com_m039_study_Summator_SUM3>:
 89c:   b510            push    {r4, lr}
 89e:   1c10            adds    r0, r2, #0
 8a0:   1c19            adds    r1, r3, #0
 8a2:   f7ff fff7       bl      894 <sum>
 8a6:   bd10            pop     {r4, pc}

Впечатление

Я немного удивлен и нет. Всё, что до этого предполагалось совпало. Оптимизированный код хорош, очень близок к тому, который попытался выше написать сам.

Часть I. Итог

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

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

Мне вначале было интересно узнать, насколько код на С будет лучше кода на Java. Я сначало написал статью, а вот этот итог пишу после. Могу сказать, что пока можете обратить внимание насколько элегантно получается решить задачу на языке С и насколько компактно получилось это в дизассемблерном виде. В опкодах Java это также можно заметить, но уже меньше. А в 3й статье, где опкоды смешиваются с С, этого практически нету.

А сейчас, как раз, немного об опкодах Java.

Часть II. Как выглядит код, генерируемый Java?


Для начала необходимо понять, как работает JVM, а точнее Dalvik VM. Для этого необходимо дизассемблировать файл *.dex, который является файлом байткодов для Dalvik VM.

Как будет выглядеть класс Summator (см. первоначальная цель)? Для этого натравим программу dexdump -d classes.dex. То, что выдала комманда:
    #5              : (in Lcom/m039/study/Summator;)
      name          : 'sum'
      type          : '(II)I'
      access        : 0x0000 ()
      code          -
      registers     : 4
      ins           : 3
      outs          : 0
      insns size    : 3 16-bit code units

00226c:            |[00226c] com.m039.study.Summator.sum:(II)I
00227c: 9000 0203  |0000: add-int v0, v2, v3
002280: 0f00       |0002: return v0
      catches       : (none)
      positions     :
        0x0000 line=73
      locals        :
        0x0000 - 0x0003 reg=1 this com/m039/study/Summator;
        0x0000 - 0x0003 reg=2 a I
        0x0000 - 0x0003 reg=3 b I


      name          : 'staticSum'
      type          : '(II)I'
      access        : 0x0008 (STATIC)
      code          -
      registers     : 3
      ins           : 2
      outs          : 0
      insns size    : 3 16-bit code units
002100:            |[002100] com.m039.study.Summator.staticSum:(II)I
002110: 9000 0102  |0000: add-int v0, v1, v2
002114: 0f00       |0002: return v0
      catches       : (none)
      positions     :
        0x0000 line=77
      locals        :
        0x0000 - 0x0003 reg=1 a I
        0x0000 - 0x0003 reg=2 b I


Здесь необходимо разглядеть add-int v0, v1, v2 и значение опкода '9000 0102'. Теперь можно перейти к тому, как выглядят эти опкоды изнутри.

Какие опкоды смотреть?

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

Для этого необходимо обратиться к документу Application.mk, а конкретнее к описанию переменной APP_ABI. Там указано, что по-умолчанию используется armv5te. Вот его и будем исследовать!

Где начать смотреть?

Сами опкоды находятся в директории armv5te. Правильно было бы рассмотреть файлы, находящиеся в этой директории, но мне захотелось сделать не совсем правильным способом, но он тоже работает — рассмотреть уже сгенерированные файлы. А точнее файл InterpAsm-armv5te.S. Мне кажется так интереснее и практичнее.

Документация

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

Исследуем опкод сложения (0x90)

Рассмотрим опкод 0x90, его код:
.L_OP_ADD_INT: /* 0x90 */
/* File: armv5te/OP_ADD_INT.S */
/* File: armv5te/binop.S */
    /*
     * Generic 32-bit binary operation.  Provide an "instr" line that
     * specifies an instruction that performs "result = r0 op r1".
     * This could be an ARM instruction or a function call.  (If the result
     * comes back in a register other than r0, you can override "result".)
     *
     * If "chkzero" is set to 1, we perform a divide-by-zero check on
     * vCC (r1).  Useful for integer division and modulus.  Note that we
     * *don't* check for (INT_MIN / -1) here, because the ARM math lib
     * handles it correctly.
     *
     * For: add-int, sub-int, mul-int, div-int, rem-int, and-int, or-int,
     *      xor-int, shl-int, shr-int, ushr-int, add-float, sub-float,
     *      mul-float, div-float, rem-float
     */
    /* binop vAA, vBB, vCC */
    FETCH(r0, 1)                        @ r0<- CCBB
    mov     r9, rINST, lsr #8           @ r9<- AA
    mov     r3, r0, lsr #8              @ r3<- CC
    and     r2, r0, #255                @ r2<- BB
    GET_VREG(r1, r3)                    @ r1<- vCC
    GET_VREG(r0, r2)                    @ r0<- vBB
    .if 0
    cmp     r1, #0                      @ is second operand zero?
    beq     common_errDivideByZero
    .endif

    FETCH_ADVANCE_INST(2)               @ advance rPC, load rINST
                               @ optional op; may set condition codes
    add     r0, r0, r1                              @ r0<- op, r0-r3 changed
    GET_INST_OPCODE(ip)                 @ extract opcode from rINST
    SET_VREG(r0, r9)               @ vAA<- r0
    GOTO_OPCODE(ip)                     @ jump to next instruction
    /* 11-14 instructions */


Если посмотреть на комментарии, то всё становится ясно, но мне бы хотелось эти комментарии самому разглядеть в коде. Поэтому дальше идет разбор куска этого кода.

Опкод 0x90 соответсвует инструкции сложения, её формат 23x. Это значит, что размер инструкции 2 байта и использует 3 регистра, 'x' означает, что больше, кроме этого, ничего нету.

Следовательно код данного опкода должен извлечь эти 3 регистра, которые передаются в формате 23x и использовать их для сложения. Но тут оказывается, что всё не так просто. Приставка 'v' к регистру, например, 'vCC' — означает virtual. И получается так, что опкод передает номера регистров, а потом инструкция извлекает значение содержащееся в указанном регистре. (Замечание: не всегда у регистров есть приставка 'v')

Это выглядит примерно так:
— Имеем опкод: 00|90 02|01 (AA|op CC|BB)*
— 90 означает опкод сложения
— Извлекаем номера регистров r9 = 0, r2 = 1, r3 = 2
— Извлекаем содержание регистров r1 = REG(r3), r0 = REG(r2)**
— Выполняем инструкцию 'add r0, r0, r1'
— Сохраняем возвращающее значение REG(r9) = r0

* для простоты восприятия добавил |, аналогично документации
** REG — является псевдокодом

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

И теперь как это всё выглядит в ассемблере или более суровое объяснение:
1. FETCH(r0, 1). В данном опкоде (0x90) используются два регистра rPC и rINST (там же), которые соответствуют r4 и r5. Если посмотреть немного ниже, то можно заметить, что rINST (FETCH_INST) это значение лежащее по адресу rPC. Следовательно можно сказать, что rINST равен значению команды FETCH(rINST, 0).

2. Извлекаются номера регистров, стандартным методом. (см. картинку)

3. GET_VREG(r1, r3). Появляется новый регистр rFP. Этот регистр указывает на область памяти для локальных переменных и аргументов. Т.е. через него можно как извлечь значения аргументов, так и записать возвращающее значение. Его можно представить как указатель на внутренние регистры VM.

4. .if 0… .endif непонятен, а всё потому, что исследовать начал с файла InterpAsm-armv5te.S! Если посмотреть в файл binop.S, то станет понятно, что проверка второго аргумента но ноль в данном опкоде отключена.

5. FETCH_ADVANCE_INST(2) рассмотрена далее

6. Выполняется сложение

7. GET_INST_OPCODE(ip) рассмотрена далее

8. SET_VREG(r0, r9) записывает возвращающее значение в соответствующий регистр, номер которого указан в r0.

9. GOTO_OPCODE(ip) рассмотрена далее

Нарисовал наглядную (надеюсь) картинку:


Можно сделать вывод, что опкод похож на тот же самый код ассемблера ('adds r0, r1, r0'), только c 2 дополнениями: обработкой виртуальных регистров и переходом к следующему опкоду.

Не рассмотренная часть

Была рассмотрена обработка виртуальных регистров. Теперь также надо понять, что делают остальные команды в опкоде, надеюсь они не уведут слишком далеко. Например туда, где инициализируются значения таких регистров как rFP и rPC. Хотя это не менее интересно, но сильное ответвление от цели.

Теперь рассмотрим остальные команды:
1. FETCH_ADVANCE_INST(2) в регистр rINST записывается следующий опкод
2. GET_INST_OPCODE(ip) в регистр ip заноситя номер опкода (уже следующего)
3. GOTO_OPCODE(ip) переходим на код следующего опкода

В этих 3х командах появляется регистр rIBASE. Надо проверить, является ли он опкодом с номером 0х00? Кажется, как будто должен. Да, так оно и есть. В коде встречается часто строчка:
adr     rIBASE, dvmAsmInstructionStart  @ set rIBASE


А значение dvmAsmInstructionStart, равно .L_OP_NOP. А код OP_NOP? Он и вправду равен 0х00.

Чего не рассмотрели, а хотелось бы

В данном разделе не было рассмотрено только то, какое у остальных (rPC, rFP) регистров начальное значение. Возможно, их рассмотрим дальше. Почему «возможно», потому что большую ценность для статьи, пока представляет rFP, а не rPC.

Исследуем опкод сложения (правильная версия)

Мне показалось, что тот способ, как был исследован опкод не совсем правильный, но так мне показалось приятнее. Но теперь рассмотрим как это можно сделать лучше, а именно как составлены файлы в папке arm5vte?

Для этого необходимо обратиться к документу README.txt.

Я немного удивлен, но тот способ, который использовал для исследования, был правильный, с точки зрения файла README.txt. Вот цитата:

«The best way to become familiar with the interpreter is to look at the generated files in the „out“ directory, such as out/InterpC-portstd.c, rather than trying to look at the various component pieces in (say) armv5te.»

Теперь, что касается нашего примера, рассмотрим файл OP_ADD_INS.S:
%verify "executed"
%include "armv5te/binop.S" {"instr":"add     r0, r0, r1"}


Это код означает, что этот файл будет, условно, заменен файлом binop.S, в котором значения $instr будет add r0, r0, r1.

Осталось рассмотреть файл binop.S:

%default {"preinstr":"", "result":"r0", "chkzero":"0"}
    /*
     * Generic 32-bit binary operation.  Provide an "instr" line that
     * specifies an instruction that performs "result = r0 op r1".
     * This could be an ARM instruction or a function call.  (If the result
     * comes back in a register other than r0, you can override "result".)
     *
     * If "chkzero" is set to 1, we perform a divide-by-zero check on
     * vCC (r1).  Useful for integer division and modulus.  Note that we
     * *don't* check for (INT_MIN / -1) here, because the ARM math lib
     * handles it correctly.
     *
     * For: add-int, sub-int, mul-int, div-int, rem-int, and-int, or-int,
     *      xor-int, shl-int, shr-int, ushr-int, add-float, sub-float,
     *      mul-float, div-float, rem-float
     */
    /* binop vAA, vBB, vCC */
    FETCH(r0, 1)                        @ r0<- CCBB
    mov     r9, rINST, lsr #8           @ r9<- AA
    mov     r3, r0, lsr #8              @ r3<- CC
    and     r2, r0, #255                @ r2<- BB
    GET_VREG(r1, r3)                    @ r1<- vCC
    GET_VREG(r0, r2)                    @ r0<- vBB
    .if $chkzero
    cmp     r1, #0                      @ is second operand zero?
    beq     common_errDivideByZero
    .endif

    FETCH_ADVANCE_INST(2)               @ advance rPC, load rINST
    $preinstr                           @ optional op; may set condition codes
    $instr                              @ $result<- op, r0-r3 changed
    GET_INST_OPCODE(ip)                 @ extract opcode from rINST
    SET_VREG($result, r9)               @ vAA<- $result
    GOTO_OPCODE(ip)                     @ jump to next instruction


Как видно код очень и очень похож, на то что было раньше. Только теперь не осталось никаких не понятных моментов. Например был комментарий "@optional op..", а почему, не понятно. Теперь ясно.

Разбирать этот опкод не вижу смысла, в нём всё уже было рассмотрено. Теперь необходимо поскорее перейти к самому главному вопросу — как заполняются аргументы у native и не native функций?

Часть II. Итог

Это было уже другой уровень чем в первой части, надо было многое узнать и вспомнить, но зато теперь структура опкода понятна! Этому можно радоваться. Да, большего смысла в этом нету, чем просто радоваться. Но при этом вы уже сможете написать свой опкод.

Из этой части вы могли почерпнуть какая архитектура используется для генерации файлов. Где и как найти соответствующий опкод.

Также у вас может быть появилось много вопросов и советую вам посмотреть презентацию разработчика Dalvik VM. В этой презентации очень хорошо показано как именно используются такие команды как GOTO_OPCODE или как организована очередь байткодов.

Не пытаюсь сразу делать выводы по достижению цели, все выводы будут в самом конце статьи. А теперь предлагаю погрузиться во внутрь Dalvik VM и понять где же исполняется этот код, но предупреждаю дальше уровень еще выше, но кода вставлять буду меньше.

Часть III. Как и где происходит выполнение кода?


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

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

Дизассемблируем

Нужно понять как заполняется опкод, для этого посмотрим вот такой вот код в разрезе:
     public class Summator {
        void test() {
            sum(44, 43);
            staticSum(42, 41);
            nSum(44, 43);
            nStaticSum(42, 41);
        }

        int sum(int a, int b) {
            return a + b;
        }

        static int staticSum(int a, int b) {
            return a + b;
        }

        native int  nSum(int a, int b);
        native static int nStaticSum(int a, int b);
    }


      name          : 'test'
      type          : '()V'
      access        : 0x0000 ()
      code          -
      registers     : 5
      ins           : 1
      outs          : 3
      insns size    : 21 16-bit code units
      outs          : 3
      insns size    : 21 16-bit code units
0022a8:               |[0022a8] com.m039.study.Summator.test:()V
0022b8: 1303 2c00     |0000: const/16 v3, #int 44 // #2c
0022bc: 1302 2b00     |0002: const/16 v2, #int 43 // #2b
0022c0: 1301 2a00     |0004: const/16 v1, #int 42 // #2a
0022c4: 1300 2900     |0006: const/16 v0, #int 41 // #29
0022c8: 6e30 2e00 3402|0008: invoke-virtual {v4, v3, v2}, Lcom/m039/study/Summator;.sum:(II)I // method@002e
0022ce: 7120 2d00 0100|000b: invoke-static {v1, v0}, Lcom/m039/study/Summator;.staticSum:(II)I // method@002d
0022d4: 6e30 2600 3402|000e: invoke-virtual {v4, v3, v2}, Lcom/m039/study/Summator;.nSum:(II)I // method@0026
0022da: 7120 2500 0100|0011: invoke-static {v1, v0}, Lcom/m039/study/Summator;.nStaticSum:(II)I // method@0025
0022e0: 0e00          |0014: return-void
      catches       : (none)
      positions     :
        0x0008 line=29
        0x000b line=30
        0x000e line=31
        0x0011 line=32
        0x0014 line=33
      locals        :
        0x0000 - 0x0015 reg=4 this Lcom/m039/study/Summator;


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

В листинге выше можно заметить, что метод native ничем не отличается от другого. Как же так? Вообще то так и должно быть, но всё равно, в листинге нету никакого намека на то, как же извлекается native метод.

Но сначала, почему бы не рассмотреть сам вызов (invoke-virtual) и сразу предположу, что там код не маленький. Поэтому сформирую заранее то, что хотелось бы там найти:
— отличие virtual от static
— само извлечение функции
— и сколько и, возможно, какие операции следуют до извлечения

Остальное пока не интересует и не должно отвлекать от исследования.

Выполнение метода

Начнем рассматривать листинг с верху вниз.

Часть кода, которая заполняет и извлекает метод sum:
0022b8: 1303 2c00      |0000: const/16 v3, #int 44 // #2c
0022bc: 1302 2b00      |0002: const/16 v2, #int 43 // #2b
0022c8: 6e30 2e00 3402 |0008: invoke-virtual {v4, v3, v2}, Lcom/m039/study/Summator;.sum:(II)I // method@002e


На первый взгляд всё очень просто. Заполняются соответствующие регистры и извлекается метод.

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

const/16 — наверно заносит значение 44 в виртуальный регистр по номеру 0, а потом значение 43. (Замечание: а если предположить, что v1 это обозначение виртуального регистра, то сомнений нет)

invoke-vritual — заносит значение this в v4 и вызывает функцию.

const/16

    %verify "executed"
    /* const/16 vAA, #+BBBB */
    FETCH_S(r0, 1)                      @ r0<- ssssBBBB (sign-extended)
    mov     r3, rINST, lsr #8           @ r3<- AA
    FETCH_ADVANCE_INST(2)               @ advance rPC, load rINST
    SET_VREG(r0, r3)                    @ vAA<- r0
    GET_INST_OPCODE(ip)                 @ extract opcode from rINST
    GOTO_OPCODE(ip)                     @ jump to next instruction


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

invoke-kind

2 виртуальных регистра заполнены, теперь переходим к файлу OP_INVOKE_VIRTUAL.S. И там глубокий ужас. Поэтому сразу попробуем понять, в чем разница между virtual (файл OP_INVOKE_VIRTUAL.S) и static (OP_INVOKE_STATIC.S).

По коду отличие в двух местах. Значение передаваемое в функцию dvmResolveMethod и не у static метода имеется дополнительный довесок ".L${opcode}_continue:".

Рассмотрим, что делает функция dvmResolveMethod.

Перед вызовом этой функции регистры заполнены следующим образом. (выдержка из комментариев):
— r0 < — method->clazz
— r1 < — CCCC *
— r2 < — METHOD_VIRTUAL или METHOD_STATIC (method type)
— r3 < — glue->method

* по документу instructions-format CCCC, хотя в комментариях написано BBBB

Теперь обратимся к коду функции dvmResolveMethod. Если посмотреть на объявление функции, то станет понятно, что 4й аргумент (см. выше) лишний:

     Method* dvmResolveMethod(const ClassObject* referrer, 
                              u4 methodIdx,
                              MethodType methodType)


Это функция возвращает указатель на структуру Method, большего знать об этой функции и не нужно. Взглянем на эту структуру.

Очень много интересных и полезных полей, но есть одно замечательно поле, которое предоставляет особый интерес — nativeFunc. Следовательно это структура также содержит указатель на native функцию.

Теперь неплохо бы узнать, где эта структура или этот метод «выполняется».

И в файле OP_INVOKE_STATIC.S и в OP_INVOKE_VIRTUAL.S в конце вызывается функция bl common_invokeMethod${routine}. Скорее всего это и есть главный обработчик структуры Method и код его можно найти в файле footer.S. Приставка «NoRange» появилась из-за первой строчки в файле — %default { ... , "routine" : "NoRange" }

Но прежде чем смотреть в footer.S, можно посмотреть, что значит тип DalvikBridgeFunc у поля nativeMethod? Если пробежаться по коду VM, то можно найти, что полю nativeMethod присваивается функция dvmResolveNativeMethod

По комментарию этой функции становится ясно — она используется для того, чтобы найти native метод (в библиотеке libNAME.so) и главное выполнить его. Ну что же, поверим ей на слово, тот случай единственный случай, когда лучше поверить.

Одним вопросом меньше, теперь стало ясно, кто и где выполняет native метод. Но всё равно, не до конца понятно где. Ведь файл footer.S так и не был рассмотрен.

Возвращаясь к функции common_invokeMethodNoRange. Можно обратить внимание на то, что она очень страшная и в ней есть ответы на то, что было не понятно в прошлой части, а именно кто заполняет такие регистры как rFP и rPC. Как можно заметить эта функция их и заполняет.

Только немного терпения и там можно понять, например, чем заполняется rINST, rPC, r2? Указателем на поле method->inst, т.е. инструкциями (байткодом). И таких моментов очень много, поэтому рассмотрение их опущу.

А вот интерес представляет native, а там еще всё проще. Если наш метод является native (проверяются соответствующие флаги), то выполняется та самая функция nativeFunc, с которой уже имели дело.

В остальном, common_invokeMethodNoRange очень похож на стандартный опкод, которые раньше рассматривали, только много дополнительных проверок.

А как быть со static или не static методами? footer.S к ним отношения, по видимому не имеет, всё что можно было про них сказать, уже было сказано в соответствующих файлах опкодов (invoke-virtual и invoke-static).

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

Часть III. Итог


В этой части была найдена функция, отвечающая за извлечение native метода. Также проследили за тем, где эта функция извлекается и где она хранится. С этими знаниями можно рассмотреть другие части языка Java и понаблюдать за ними в коде Dalvik VM.

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

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

Итог


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

Но сначала хочу подчеркнуть пару особенностей, как составлена статья.

Вы могли встретить в тексте слово «Замечание», оно было добавлено после написание всей статьи в черновик. А потом мне показалось, что стоит эти замечания добавить.

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

А теперь немного о том, что мне кажется интересным и является результатом исследований. Они могут показаться вам банальными, многие вещи написаны в спецификации языка Java, но после данной статьи гораздо проще разобраться в этих структурах как есть.

Вот основные моменты:

1. Вызов метода, какой бы он ни был static, virtual (есть также с приставкой quick), он вызов метода и по себе очень сложный, если сравнивать с простым С (и даже С++). Вот, например, допустим у вас есть wraper для Box2D. Если каждый раз (в бесконечном цикле) вызывать метод из этой обертки, для того, чтобы проверить пересекаются ли объекты, то возникает соответствующий вопрос — зачем? Это лучше и нужно сделать на С, а вот создать мир и проинициализровать объекты можно и в Java.

2. Опкоды, очень близки по своему функционалу к ассемблерным вставкам, дабы они так и реализованы с помощью ассемблера. Естественно, они лучше чем вызов сторонней функции через JNI, но хуже чем просто это сделать на С.

Хуже они по двум критериям:

1) Используют для своих вызов обертку через виртуальные регистры, что очень сильно похоже на то, как С компилятор выдает в debug режиме. Можно даже сказать, что опкод по скорости очень похож на не оптимизированную версию на С.

2) В каждом опкоде есть код извлечения следующего опкода, но он не большой. После извлечения происходит переход на следующий, что уже похоже на простую инструкцию bl.

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

Я часто замечаю, что разработчики оочень часто пользуются стереотипами (по поводу языков Java и С/C++) и не проявляют ни малейшего действия для того, чтобы их разрушать, а без этого никуда.

Если вам интересен дополнительный материал то можете посмотреть в папку docs на файл jni-tips.html и презентацию 2008 года разработчика Davik VM. Также есть интересный проект smali, но у меня руки до него не добрались.

P.S. Сразу извиняюсь, если допустил какую-то ошибку, пожалуйста, поправьте меня и тогда сразу же внесу изменения.
Поделиться публикацией

Комментарии 14

    +8
    когда я вижу слово «андройд» (через «й»), моя рука самопроизвольно тянется к пистолету
      0
      Спасибо, исправил. Только вы не говорите не кому, а то кто-нибудь может вас убить. ;)
        +7
        «никому», ай, самому бы не застрелиться)
          0
          нйкому
      +3
      хм… а разве JIT-компилятор Дальвика не должен бы скомпилировать рассмотренную dex-функцию в аналогичный набор arm-инстркукций?

      если вы это с целью оптимизации производительности, то — замеры в студию! очень любопытно
        +4
        Нет, не с целью производительности. Для производительности врятли нужно разбирать инструкции и анализировать код, так как сделано в статье. Там нужно просто сделать замеры. Сам же хотел просто разобраться где что лежит, чтобы если захочу вернусь попозже.

        Про JIT компиляцию сходу и не скажу, на деле — да оно должно преобразоваться в аналогичный набор, но не проверял. По наитию кажется, что JIT должен быть пристройкой к уже существующему варианту, но на тот момент не было интересно и очень, очень не хотел запутяться еще и в JIT.
          +1
          Из моего опыта работы над реализации Джава машины — JIT существенно отличается от интерпретатора тем, что подставляет сразу вычисленое значение адреса нативной функции в генерируемый код, если функция не виртуальная (private, static). Вся логика определения calling convention, адреса функции делается один раз и генерирутся нужный вариант. В случае не-нативной функции, без виртуальности — вызов может заменен на подстановку кода вызываемой функции, если она не большая. С виртуальной функцией всё сложнее, особенно нативной.
        +9
        хабр торт!
          +3
          Отличная статья, спасибо.
            +1
            А не смотрели в сторону JNA — Java Native Access. Возможно ли использовать на андроиде его? И если да, до каковы потери по производительности по сравнению с обычным JNI?
              +1
              Вам крайне повезло, этот вопрос есть в JNA FAQ (самый последний). :)

              The calling overhead for a single native call using JNA interface mapping can be an order of magnitude (~10X) greater time than equivalent custom JNI (whether it actually does in the context of your application is a different question).

              И при этом эта цитата не относится к андройду.

              Если честно, где-то слышал, но только сейчас посмотрел информацию по JNA. По тем ссылкам, которые нашел, даже если и есть JNA для андроида, то в функциях через JNA есть какой-то довесок (overhead). Т.е. если в данный момент выбирать между JNA или JNI, то лучше работающий и прямой (понятный) JNI.

              Но если вам не нужно часто вызывать функцию, то overhead не так важен.
              0
              Очень классная статья, спасибо
                0
                'thiz' rulez!
                  +2
                  Нет слов. Охуенная статья. Спасибо.

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

                  Самое читаемое