OpenOCD, ThreadX и ваш процессор

    Данная заметка может оказаться полезной для людей, который пишут bare-metal код и используют ThreadX в своих задачах (по собственному выбору или по навязыванию SDK). Проблема в том, что что бы эффективно отлаживать код под ThreadX или другую многопоточную операционную систему нужно иметь возможность видеть эти самые потоки, иметь возможность посмотреть стек-трейс, состояние регистров для каждого потока.

    OpenOCD (Open On Chip Debugger) заявляет поддержку ThreadX, но не сильно явно оговаривает её широту. А штатно, на момент написания статьи, в версии 0.8.0, это всего два ядра: Cortex M3 и Cortex R4. Мне же, волею судеб, пришлось работать с чипом Cypress FX3 который построен на базе ядра ARM926E-JS.

    Под катом рассмотрим что нужно сделать, что бы добавить поддержку вашей версии ThreadX для вашего CPU. Акцент делается на ARM, но, чисто теоретически, вполне может подойти и для других процессоров. Кроме того, рассматривается случай, когда доступа к исходникам ThreadX нет и не предвидится.

    С первых строк сразу же огорчу: без ассемблера никуда. Нет, писать на нём нам не придётся, но читать код — да.

    Начнём со знакомства с реализацией поддержки ThreadX в OpenOCD. Это всего один файл: src/rtos/ThreadX.c.

    Поддерживаемая система описывается структурой ThreadX_params, которая содержит информацию о имени таргета, «ширины» указателя в байтах, набор смещений в структуре TX_THREAD до необходимых служебных полей, а так же информацию о том, как сохраняется контекст потока при переключении (т.н. stacking info). Сами поддерживаемые системы регистрируются при помощи массива ThreadX_params_list.

    Со всеми параметрами, кроме последнего, проблем нет: ширина указателя обычно равна разрядности процессора, смещения считаются ручками (и то, почти всегда они неизменны).

    Интересный вопрос: откуда брать информацию по стекингу? А ведь информации там немало:
    • направление роста стека (ну это просто)
    • число регистров в системе (это тоже просто, запускаем на имеющейся версии OpenOCD «info registers» и считаем число строк).
    • выравнивание фрейма на стеке, это значение я получил случайно, для Cortex M3/R4 оно указано 8 байт, для ARM926E-JS — 0 (т.е. без выравнивания). На самом деле выравнивание по 4, но память выделенная при помощи tx_byte_alloc() уже выровнена, а использование стека всегда кратно 4. В общем, попробуйте значения 0, 4 и так далее.
    • массив смещений в стеке (относительно текущей вершины) по которым лежат значения конкретных регистров (размер массива равен числу регистров в выводе «info registers»).

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

    Более того, забегая вперёд, как оказалось для ядер Cortex M3/R4 используется одна схема стекинга, а для ARM926E-JS — две! Всё ради экономии.

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

    Кооперативный подход работает для потоков одинакового приоритета которым не задан слайс времени (0). Т.е. если поток А и Б имеют одинаковый приоритет, поток А начал работу, то поток Б не получит управление пока А:
    • не завершится
    • не вызовет функции, которая приводит к решедулингу (sleep, ожидание на очереди, мутексе, семафоре и т.д.)

    Если слайс времени задан, то по его завершению поток будет прерван и управление передастся другому следующему в состоянии Ready (для случая, когда поток засыпает, но не выработал свой слайс, так же сработает кооперативный подход). Здесь уже работает вытесняющий подход. Для его работы нужен таймер и прерывания от него с определённой периодичностью. Так же поток А из примера выше, может быть вытеснен потоком В, если его приоритет выше.

    Понятно, что контекст потока сохраняется когда он передаёт управление кому-то и восстанавливается, когда он получает управление. Поймём как это происходит — поймём что нужно описывать в массиве смещений регистров.

    Не буду вдаваться в подробности, как я выяснял где и как спрятались основные части планировщика, много тут понамешалось: и смекалка, и удача, и гугл, и дизассемблер. Но приведу основные компоненты оного:
    1. _tx_timer_interrupt() — функция вызывается из контекста прерывания таймера, по сути отвечает за вытесняющую часть планировщика.
    2. _tx_thread_context_save() (или _tx_thread_vectored_context_save()) и _tx_thread_context_restore() — пара функций предназначенных для вызова из прерываний для сохранения и восстановления контекста. При восстановлении контекста производится попытка решедулинга.
    3. _tx_thread_system_return() — часть кооперативного подхода. Вызывается в конце любой цепочки вызовов, которая приводит к решедулингу.
    4. и, наконец, _tx_thread_schedule() — самая важная функция для анализа и, пожалуй самая простая из всех вышеперечисленных.

    Я изучал листинги всех этих функций, но если снова потребуется прикручивать поддержку для неподдерживаемого процессора, я буду акцентировать на последних трёх. Но начну с последней, и только после этого (если не хватит информации) буду изучать другие.

    Посмотрим на её листинг (некоторую косвенную адресацию я заменил на реальные символы, сами символы
    смотрятся в elf-файле при помощи arm-none-eabi-nm):
    40004c7c <_tx_thread_schedule>:
    40004c7c:       e10f2000        mrs     r2, CPSR
    40004c80:       e3c20080        bic     r0, r2, #128    ; 0x80
    40004c84:       e12ff000        msr     CPSR_fsxc, r0
    40004c88:       e59f104c        ldr     r1, [pc, #76]   ; 40004cdc <_tx_thread_schedule+0x60>
    40004c8c:       e5910000        ldr     r0, [r1]
    40004c90:       e3500000        cmp     r0, #0
    40004c94:       0afffffc        beq     40004c8c <_tx_thread_schedule+0x10>
    40004c98:       e12ff002        msr     CPSR_fsxc, r2
    40004c9c:       e59f103c        ldr     r1, [pc, #60]   ; 40004ce0 <_tx_thread_schedule+0x64>
    40004ca0:       e5810000        str     r0, [r1]
    40004ca4:       e5902004        ldr     r2, [r0, #4]
    40004ca8:       e5903018        ldr     r3, [r0, #24]
    40004cac:       e2822001        add     r2, r2, #1
    40004cb0:       e5802004        str     r2, [r0, #4]
    40004cb4:       e59f2028        ldr     r2, [pc, #40]   ; 40004ce4 <_tx_thread_schedule+0x68>
    40004cb8:       e590d008        ldr     sp, [r0, #8]
    40004cbc:       e5823000        str     r3, [r2]
    40004cc0:       e8bd0003        pop     {r0, r1}
    40004cc4:       e3500000        cmp     r0, #0
    40004cc8:       116ff001        msrne   SPSR_fsxc, r1
    40004ccc:       18fddfff        ldmne   sp!, {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, sl, fp, ip, lr, pc}^
    40004cd0:       e8bd4ff0        pop     {r4, r5, r6, r7, r8, r9, sl, fp, lr}
    40004cd4:       e12ff001        msr     CPSR_fsxc, r1
    40004cd8:       e12fff1e        bx      lr
    40004cdc:       4004b754        .word   0x4004b754 ; _tx_thread_execute_ptr
    40004ce0:       4004b750        .word   0x4004b750 ; _tx_thread_current_ptr
    40004ce4:       4004b778        .word   0x4004b778 ; _tx_timer_time_slice
    

    Функция до безумства простая:
    1. разрешить прерывания (строки 40004c7c-40004c84)
    2. дождаться, что кто-то взведёт _tx_thread_execute_ptr (40004c88-40004c94) — следующий для исполнения тред
    3. запретить прерывания, а точнее — восстановить статусный регистр (40004c98)
    4. сохранить указатель _tx_thread_current_ptr в r0 (40004c9c-40004ca0)
    5. увеличить значение tx_thread_run_count текущего треда на 1 (40004ca4, 40004cac-40004cb0)
    6. получить значение tx_thread_time_slice текущего треда и присвоить его _tx_timer_time_slice (40004ca8, 40004cb4, 40004cbc)
    7. установить новый указатель на стек, сохранённый в структуре потока (прочитать tx_thread_stack_ptr) (40004cb8)

    А вот начиная с 40004cb8 идёт код который, собственно и восстанавливает контекст нового потока.

    Сначала вычитываются два значения в регистры r0, r1:
    40004cc0:       e8bd0003        pop     {r0, r1}
    

    Далее идёт сравнение r0 с нулём:
    40004cc4:       e3500000        cmp     r0, #0
    

    Очевидно, что эти значения, по крайней мере r0, часть контекста (ведь стековый регистр уже настроен на стек восстанавливаемого треда), но не совсем похоже, что это регистры. А сравнение с нулём подразумевает какое-то ветвление. Продолжая анализ, видим, что если r0 != 0, то выполняется код:
    40004cc8:       116ff001        msrne   SPSR_fsxc, r1
    40004ccc:       18fddfff        ldmne   sp!, {r0, r1, r2, r3, r4, r5, r6, r7, r8, r9, sl, fp, ip, lr, pc}^
    

    Собственно говоря это и похоже на восстановление контекста. Причём значение регистра r1 — это сохранённое значение статусного регистра CPSR. Если строчка 40004ccc выполнится, то управление дальше не пойдёт: восстановится регистр pc (r15) и программа после этой точки вернётся в то место, откуда она была прервана.

    Отлично, теперь мы можем написать такую табличку:
      Смещение     Регистр
      --------     -------
      0            флаг
      4            CPSR
      8            r0
      12           r1
      16           r2
      20           r3
      24           r4
      28           r5
      32           r6
      36           r7
      40           r8
      44           r9
      48           sl (r10)
      52           fp (r11)
      56           ip (r12)
      60           lr (r14)
      64           pc (r15)
    

    Каждый регистр и каждый флаг — 32 бит или 4 байта, соответственно на этот контекст нужно 17*4 = 68 байт. Логично, что дальше идёт стек, каким он был на момент прерывания.

    Но, как видим, это часть работы. У нас есть этот самый флаг. И если его значение 0, то выполняется код:
    40004cd0:       e8bd4ff0        pop     {r4, r5, r6, r7, r8, r9, sl, fp, lr}
    40004cd4:       e12ff001        msr     CPSR_fsxc, r1
    40004cd8:       e12fff1e        bx      lr
    

    Судя по всему, это тоже контекст, только несколько сокращённый. Более того, возврат из него происходит как из обычной функции, а не восстановлением регистра pc. Переписав табличку выше, получаем:
      Смещение     Регистр
      --------     -------
      0            флаг
      4            CPSR
      8            r4
      12           r5
      16           r6
      20           r7
      24           r8
      28           r9
      32           sl (r10)
      36           fp (r11)
      40           lr (r14)
    

    Для этого контекста нужно всего 11*4 = 44 байта.

    Пользуясь гуглом, просмотром листингов дизассемблера, а так же изучением соглашений по вызову процедур приходим к пониманию, что этот тип контекста используется когда работает кооперативная многозадачность: т.е. когда мы вызвали tx_thread_sleep() или иже с ними. А т.к. такое переключение, по сути, просто вызов функции, то и контекст можно сохранять согласно соглашениям о вызовах, по которому, мы имеем право между вызовами не сохранять значения регистров r0-r3, r12. Более того, нам не нужно сохранять pc — вся необходимая информация уже содержится в rl — адресе возврата из tx_thread_sleep(). Выгода на лицо. Кортексы обычно используются на системах с большим количеством памяти, нежели ARM9E, там к подобных ухищрениям не прибегают и используют один тип стекинга.

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

    Вот собственно всё и готово, что бы понять какие переделки нужны в OpenOCD:
    • нужно доработать механизм регистрации таргетов, что бы была возможность использовать несколько вариантов стекинга для одного таргета;
    • собственно составить описание таргета.

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

    Первым делом смотрим вывод команды 'info registers', смотрим сколько регистров и в каком порядке выводится, составляем такую рыбу:
    static const struct stack_register_offset rtos_threadx_arm926ejs_stack_offsets_solicited[] = {
            { , 32 },               /* r0        */
            { , 32 },               /* r1        */
            { , 32 },               /* r2        */q
            { , 32 },               /* r3        */
            { , 32 },               /* r4        */
            { , 32 },               /* r5        */
            { , 32 },               /* r6        */
            { , 32 },               /* r7        */
            { , 32 },               /* r8        */
            { , 32 },               /* r9        */
            { , 32 },               /* r10       */
            { , 32 },               /* r11       */
            { , 32 },               /* r12       */
            { , 32 },               /* sp (r13)  */
            { , 32 },               /* lr (r14)  */
            { , 32 },               /* pc (r15)  */
            { , 32 },               /* xPSR      */
    };
    

    Здесь 32 — битность регистра. Для ARM всегда 32. Первая колонка заполняется при помощи табличек, которые мы записали выше, когда анализировали восстановление контекста. Учитываем специальные значения: -1 — данный регистр не сохраняется, -2 — стековый регистр, восстанавливается из структуры потока.

    Заполненная рыба для solicited контекста получается такой:
    static const struct stack_register_offset rtos_threadx_arm926ejs_stack_offsets_solicited[] = {
            { -1, 32 },             /* r0        */
            { -1, 32 },             /* r1        */
            { -1, 32 },             /* r2        */
            { -1, 32 },             /* r3        */
            {  8, 32 },             /* r4        */
            { 12, 32 },             /* r5        */
            { 16, 32 },             /* r6        */
            { 20, 32 },             /* r7        */
            { 24, 32 },             /* r8        */
            { 28, 32 },             /* r9        */
            { 32, 32 },             /* r10       */
            { 36, 32 },             /* r11       */
            { -1, 32 },             /* r12       */
            { -2, 32 },             /* sp (r13)  */
            { 40, 32 },             /* lr (r14)  */
            { -1, 32 },             /* pc (r15)  */
            {  4, 32 },             /* xPSR      */
    };
    

    Для interrupt контекста попробуйте написать сами или посмотрите в исходники.

    Что это даст:
    • вывод списка потоков по «info threads»
    • стектрейс индивидуально для потока: «thread apply all bt»
    • переключением между потоками: «thread 3»
    • переключением между фреймами: «frame 5»
    • индивидуальный просмотр состояния регистров каждого потока

    команды даны для gdb.

    В общем, счастливой отладки!

    Ресурсы:


    PS не хватает хаба «Обратная разработка» и подсветки для разных ассемблеров ;-)

    UPD /2015-08-15/: Изменения попали в основную ветку OpenOCD: openocd.zylin.com/#/c/2848
    • +12
    • 12.4k
    • 7
    Share post

    Comments 7

      +1
      Хаб называется «Реверс-инжиниринг», а вот подсветки ассемблеров действительно не хватает.
        0
        О как, пробовал Reverse engineering и Обратная разработка, а так не догадался. Спасибо за наводку.
        0
        интересно, а если поток оставил частичный контекст, сознательно отдав остаток кванта, а возобновляется затем по таймеру? Несохраненные регистры будут содержать данные другого потока, или инициализированы ядром RTOS?
          0
          Заглянул в свою копию исходников ThreadX. Если поток оставил частичный контекст, то в любом случае после его возобновления в несохранённых регистрах будет мусор от предыдущего потока + самой функции schedule.
            0
            Спасибо, я так и думал. Хорошо, что в мире RTOS процессы априори дружественны друг другу — в «больших» системах такое поведение считалось бы уязвимостью ;)
              0
              в «больших» системах такое поведение считалось бы уязвимостью

              В «больших» системах процессы в контексте ядра тоже дружественны друг другу. Если вы заглянете в исходники linux, в реализации функций _switch_to (которые выполняют архитектурно-специфичную работу по переключению контекста), то увидите, что там контекст тоже сохраняется и восстанавливается не полностью. Вот, например: git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/tree/arch/arm64/kernel/entry.S#n556
                0
                Ну почему? Это по соглашениям — «мусорные» регистры. Далее, отдача остатка кванта — это, по сути, вызов системной функции, соответственно, возвращение в место выполнения — это возврат из (череды) функций. r0, r1 при этом используются для возврата значения, при этом пишутся туда значения непосредственно перед возвратом, уже после получения управления. Т.е. это всё равно что рассчитывать на какие-то значения в регистрах r0-r3 после возврата из какой-то функции с типом возвращаемого значения — void: в одном и том же коде на разных компиляторах или разных параметрах сборки там будут абсолютно непредсказуемые значения. Если ваш код на этом построен, то да, безопасности может прийти каюк.

                Собственно такая проблема может быть только если вы пишите функцию на ассемблере либо баг в компиляторе. В первом случае — вы сами себе враг, во втором случае — главное догадаться посмотреть диззасемблерный листинг, если там будет такое:
                bl _proc
                cmp r2, #0
                то похоже, что-то прогнило в Датском королевстве.

          Only users with full accounts can post comments. Log in, please.