В контроллере ARM есть физическая поддержка стека (имеется специальный регистр SP – Stack Pointer), хотя и нет каких-то особых команд для работы с ним – всё выполняется за счёт команд работы с памятью.
Многое из сказанного далее верно и для других систем (с оговорками, конечно).
Например, есть такие интересные команды STMDB (Store Multiple Decrement Before) и LDMIA (Load Multiple Increment After), которые умеют запихивать в память (доставать из памяти) сразу по куче регистров, изменяя указатель после записи каждого регистра.
Думаю, понятно, что первую команду можно использовать как операцию PUSH в x86-процессорах. Там она помещает содержимое одного указанного регистра в стек и изменяет указатель на вершину стека. Вторую – как POP, которая действует ровно наоборот – изменяет адрес вершины стека и записывает содержимое памяти по этому указателю в заданный регистр.
блаблабла...
Так, в общем-то, и сделано – такие команды (PUSH/POP) имеются в Thumb-режиме работы процессора. Но не надо думать, что это особые команды – все команды из этого набора специальным модулем декодируются в самые обычные ARM-команды, которые потом и исполняются.
Рассмотрим процесс этот поподробнее.
Например, в некоторый момент времени стек выглядит подобным образом:

После чего выполняется код:
MOV R0, #0xDEADBEEF ; Загрузить в регистр R0 число 0xDEADBEEF
PUSH {R0} ; Затолкнуть его в стек
Что произошло со стеком в этот момент?
Сначала изменился указатель стека в регистре SP: 0xF00 уменьшилось на 4 (т.к. размер любого регистра 32 бита, 4 байта) и стало 0xEFC. Дальше содержимое регистра R0 перекочевало в память по этому самому указателю. Всё. Вот картинка для наглядности:

Но ведь команда позволяет записать сразу несколько регистров! Как же процессор с ней разберётся?
Вот примерный код:
PUSH {R0,R5,LR} ; Для наглядности запишем аж 4 регистра
Регистр LR (Link Register), если кто не знает, показывает адрес команды, следующей за вызовом процедуры инструкцией BL (Branch with Link). То есть мы запоминаем его в стеке, чтоб не забыть, куда нам после завершения своих дел отдавать управление.
При чтении/записи нескольких регистров есть следующее правило – меньшие адреса достанутся регистрам с меньшим номером.
Порядок действий будет таков:
1. Уменьшился указатель стека на 4 и стал 0x0EF8.
2. Записалось значение регистра LR (как старший, у него номер 14 – R14).
3. Уменьшился указатель стека ещё на 4 и стал 0x0EF4.
4. Записалось значение регистра R5.
5. Уменьшился указатель стека ещё на 4 и стал 0x0EF0.
6. Записалось значение регистра R0.
На картинке показано, что будет в стеке после этих дел:

Конечно, там числа какие-то везде, но я условно обозначил, какие регистры куда попали.
Так, с записью разобрались. Как быть с чтением? А так же.
POP {R1} ; Достать элемент с вершины стека регистр R1
Вспоминаем полную инструкцию, тождественную этой – LDMIA. Значит, сначала будет читаться память, а потом увеличиваться адрес.
1. Поместим значение этой ячейки памяти в регистр R1.
2. Увеличим адрес на 4: 0x0EF4.
При этом элемент по адресу 0x0EF0 становится «недействительным» - никого теперь не заботит, что с ним станет.
Картинка:

А теперь несколько:
POP {R0,PC}
Такие команды легко встретить в конце различных процедур. Регистр PC (Program Counter) указывает всегда на следующую команду, которую будет процессор исполнять. И помним: младшие адреса – младшим регистрам. Всё честно и справедливо.
Значит так:
1. Запишем элемент с вершины стека (тот, что на краю) в регистр R0.
2. Увеличим указатель на 4: 0x0EF8.
3. Запишем элемент с новой уже вершины (содержимое LR ранее) в регистр PC (он R15, старший то есть), при этом мы окажемся в другой процедуре, где вызывали текущую, сразу после команды вызова.
4. Увеличим указатель на 4: 0x0EFC.

Так что всё достаточно с этим просто.
Компиляторы выделяют специально регион памяти для стека (а их может быть несколько) и везде можно настроить его размер. Помните – если у вас в памяти на пути стека встретятся какие-то переменные (ну он оказался немного больше ожидаемого), то вы их потеряете и вполне вероятно, что программа станет вести себя некорректно. Такого род ошибки очень коварны и могут долго прятаться, вылезая в самых неожиданных местах. Потому всегда давайте ему достаточно места.
Одним PUSH’ем теряем значение переменной по адресу 0x04FC:

Кстати, если писать на ассемблере или же свой компилятор, нам никто не мешает заставить расти стек вверх, в сторону больших адресов: есть команды STMIA и LDMDB. Не знаю, конечно, зачем это может понадобиться, но никто не мешает так делать.
В добавление – для каждого режима работы (User, IRQ, FIQ, …) надо настраивать отдельный стек.