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

Специально для сайта ITWORLD.UZ. Новость взята с сайта Хабр