Управление памятью в XNU - Стек (Часть 2)
Введение Link to heading
В предыдущей статье мы кратко рассмотрели устройство виртуальной памяти в системах на базе Darwin и научились собирать статистику по ключевым параметрам. Теперь углубимся в одну из центральных составляющих этой системы - стек вызовов (call stack), который играет критическую роль в управлении выполнением программ, размещении временных данных и поддержании локального состояния функций. Объём доступного стека - важная метрика, напрямую влияющая на стабильность и предсказуемость поведения приложений.
Стек - это не просто структура данных. Это важнейший инструмент, с помощью которого компилятор и операционная система координируют выполнение вложенных вызовов, обеспечивают возврат из функций и управляют жизненным циклом локальных переменных. Именно из-за стека становятся возможными такие механизмы, как рекурсия, передача аргументов по значению, контекст переключения потоков и многое другое.
В этой статье мы рассмотрим, как можно программно исследовать поведение стека на уровне пользовательского кода, измерить его параметры, определить глубину текущего использования и смоделировать его переполнение. Мы создадим небольшое тестовое приложение и попытаемся определить размер стека в контексте главного потока.
Обзор архитектуры потоков в Darwin Link to heading
В Darwin (ядро XNU, на котором основаны macOS и iOS), потоки реализованы на основе POSIX pthread. Каждый поток имеет:
- Собственный стек, инициализируемый при создании потока;
- Значение stack size, определяющее максимальный размер стека;
- Stack Base (верх стека) и Stack Limit (нижняя граница стека).
На архитектуре x86_64 и arm64 стек обычно растёт вниз - от большей адресации к меньшей. Верх стека (
top) - начальная точка, нижняя граница (bottom) - адрес, за который стек заходить не должен.
На macOS размер стека по умолчанию для пользовательских потоков составляет около 512 КБ. Однако стек главного потока (main thread) может быть существенно больше - вплоть до нескольких мегабайт, в зависимости от платформы и контекста выполнения. Чтобы проверить фактический размер стека, давайте напишем небольшое приложение, которое выведет его на экран с помощью API из Foundation:
import Foundation
import Darwin
if Thread.isMainThread {
print("Main thread stack size: \(Thread.main.stackSize) bytes")
}
Этот код выполняется в главном потоке и возвращает значение свойства stackSize, отражающее размер стека, выделенный системой.
Результаты выполнения: Link to heading
- macOS:
512 KB - iOS:
512 KB
На первый взгляд - всё просто. Но возникает закономерный вопрос: почему такие значения, если в документации Apple утверждается следующее:
- macOS: размер стека главного потока - 8 МБ, может увеличиваться до 16 МБ;
- iOS: стек главного потока, в зависиомсти от версии iOS и устройства, составляет от 1 МБ до 8 МБ, и он не может быть изменён вручную.
Очевидно, что значение Thread.main.stackSize отражает не фактический размер стека, выделенного системой, а скорее логический или запрашиваемый размер, доступный из уровня API. Для получения точного системного значения потребуется использовать низкоуровневые функции POSIX, такие как pthread_get_stacksize_np.
Давайте разберёмся, как на самом деле определить фактический размер стека и почему это важно не только при работе с низкоуровневым кодом, но и на уровне обычной прикладной логики. API, предоставляемые Foundation, могут показывать не всю картину. Для точной информации лучше обратиться к системному уровню и воспользоваться POSIX API.
Мы будем использовать функцию pthread_get_stacksize_np - это непортабельное (non-portable) расширение POSIX API, предоставляемое в системах на базе Darwin (включая macOS и iOS). Она используется для получения фактического размера стека для конкретного потока исполнения. Использовать pthread_get_stacksize_np имеет смысл при разработке системного или высоконагруженного прикладного кода, где важен контроль над использованием стека.
Модифицируем наш код следующим образом:
import Foundation
import Darwin
if Thread.isMainThread {
print("Foundation: \(Thread.main.stackSize) bytes")
print("POSIX: \(pthread_get_stacksize_np(pthread_self()))")
}
Результаты выполнения: Link to heading
- macOS:
8 Mb - iOS:
1 Mb
Теперь мы получили фактические значения, которые система выделяет под стек главного потока. Но остаётся вопрос: это расхождение - ошибка в исходном коде API Foundation или особенности реализации POSIX? Для этого мы можем посмотреть реализацию в исходниках opensource swift-corelibs-foundation класса Thread:
open var stackSize: Int {
get {
var size: Int = 0
return withUnsafeMutablePointer(to: &_attr) { attr in
withUnsafeMutablePointer(to: &size) { sz in
pthread_attr_getstacksize(attr, sz)
return sz.pointee
}
}
}
set {
// Setter implementation
..
}
}
Нас интересует только pthread_attr_getstacksize - функция, которая позволяет узнать размер стека по умолчанию, используемого при создании новых потоков pthread. Она не анализирует текущий поток, а лишь возвращает значение, установленное системой, если не задано иное. Чтобы разобраться и быть точно уверенными, что все работает правильно, давайте заглянем глубже - проанализируем данный код в Xcode с помощью отладчика.
Чтобы изучить реализацию POSIX-функции pthread_attr_getstacksize, необходимо выполнить следующие шаги:
Установите брейкпоинт в Xcode, как показано на скриншоте:

Запустите приложение в режиме отладки и проанализируйте соответствующий участок ассемблерного кода:

Этот дизассемблированный фрагмент показывает реализацию pthread_attr_getstacksize в libsystem_pthread.dylib (используется в macOS / iOS).
libsystem_pthread.dylib`pthread_attr_getstacksize:
-> 0x216cb862c <+0>: ldr x9, [x0]
0x216cb8630 <+4>: mov w10, #0x4441 ; =17473
0x216cb8634 <+8>: movk w10, #0x5448, lsl #16
0x216cb8638 <+12>: cmp x9, x10
0x216cb863c <+16>: b.ne 0x216cb8660 ; <+52>
0x216cb8640 <+20>: mov x8, x0
0x216cb8644 <+24>: mov w0, #0x0 ; =0
0x216cb8648 <+28>: ldr x8, [x8, #0x18]
0x216cb864c <+32>: cmp x8, #0x0
0x216cb8650 <+36>: mov w9, #0x80000 ; =524288
0x216cb8654 <+40>: csel x8, x9, x8, eq
0x216cb8658 <+44>: str x8, [x1]
0x216cb865c <+48>: ret
0x216cb8660 <+52>: mov w0, #0x16 ; =22
0x216cb8664 <+56>: ret
Он работает с POSIX-атрибутами потока (pthread_attr_t) и извлекает значение размера стека. Ниже приведён подробный пошаговый разбор ассемблера для архитектуры ARM64.
Функция
int pthread_attr_getstacksize(const pthread_attr_t *restrict attr, size_t *restrict stacksize):
- Принимает указатель
attr- структуру атрибутов потока;- Записывает в
*stacksizeразмер стека;- Возвращает
0при успехе или код ошибки.
Подробный разбор кода Link to heading
0x216cb862c <+0>: ldr x9, [x0]
Загружаем содержимое по адресу x0 (указатель на pthread_attr_t) в регистр x9. По сути читаем сигнатуру структуры.
0x216cb8630 <+4>: mov w10, #0x4441
0x216cb8634 <+8>: movk w10, #0x5448, lsl #16
Формируем 32-битную сигнатуру w10 = 0x54484441 (ASCII 'THDA' - “Thread Attribute”). Это метка, чтобы проверить валидность структуры.
0x216cb8638 <+12>: cmp x9, x10
0x216cb863c <+16>: b.ne 0x216cb8660 ; <+52>
Сравниваем значение x9 (полученное из структуры) с ожидаемой сигнатурой. Если структура невалидна, переходим к <+52>, где возвращается ошибка EINVAL (22).
0x216cb8640 <+20>: mov x8, x0
0x216cb8644 <+24>: mov w0, #0x0
Сохраняем x0 в x8 для дальнейшей работы. В w0 подготавливаем код возврата = 0 (успех).
0x216cb8648 <+28>: ldr x8, [x8, #0x18]
Загружаем из структуры pthread_attr_t по смещению 0x18 поле, где хранится пользовательский размер стека (если установлен явно).
0x216cb864c <+32>: cmp x8, #0x0
Проверяем: задан ли размер явно (!= 0)?
0x216cb8650 <+36>: mov w9, #0x80000 ; =524288 (512 KB)
Готовим значение по умолчанию: 512 КБ.
0x216cb8654 <+40>: csel x8, x9, x8, eq
Если x8 == 0, выбираем x9 (524288); иначе используем x8 (пользовательский размер). Инструкция csel - conditional select.
0x216cb8658 <+44>: str x8, [x1]
Записываем полученный размер стека по адресу x1 (в *stacksize).
0x216cb865c <+48>: ret
Завершаем выполнение, возвращая 0 (успех).
0x216cb8660 <+52>: mov w0, #0x16 ; EINVAL = 22
0x216cb8664 <+56>: ret
Если сигнатура неверна - возвращаем ошибку EINVAL.
Таким образом, если поле stacksize не задано (0), функция возвращает значение по умолчанию - 524288 байт (512 КБ). В противном случае - возвращает явно заданное значение. Если структура pthread_attr_t повреждена или не инициализирована корректно, возвращается ошибка EINVAL (код 22).
Вывод Link to heading
- Атрибут
stacksizeпри вызове функции был явно не установлен (0x18 == 0), поэтому используется значение по умолчанию: 524288 байт (512 КБ). - Стек устанавливается при создании
pthreadпотока, иpthread_attr_getstacksizeпросто извлекает это значение. - Этот механизм лежит в основе
Thread.stackSizeв Foundation - и именно из-за таких реализаций в POSIX может наблюдаться несогласованность с реальным размером стека у уже существующего потока (например, главного потока).
Получение размера стека по умолчанию и статистика его использования Link to heading
Исходный код
import Foundation
import Darwin
/// Получает размер стека по умолчанию, используемого для новых POSIX-потоков (pthread).
/// Это значение задаётся системой и используется при создании нового потока,
/// если не был явно указан размер стека.
///
/// - Returns: Размер стека в байтах (например, 512 * 1024).
func getDefaultThreadStackSize() -> Int {
var stackSize: Int = 0
var attr = pthread_attr_t()
// Инициализируем структуру атрибутов потока
pthread_attr_init(&attr)
// Получаем размер стека, заданный в атрибутах (или значение по умолчанию)
pthread_attr_getstacksize(&attr, &stackSize)
// Освобождаем ресурсы, связанные с атрибутами
pthread_attr_destroy(&attr)
return stackSize
}
/// Проверяет текущий стек, вычисляя приблизительный указатель стека (SP),
/// верхнюю и нижнюю границу стека, и оставшееся безопасное пространство.
/// Генерирует ошибку, если стек подходит слишком близко к нижней границе.
///
/// - Parameter depth: Текущая глубина вызова (используется при рекурсии).
func checkStack(depth: Any) {
var x: UInt8 = 1
/// Приблизительно возвращает текущий указатель стека
func approximateSP(_ p: UnsafeMutableRawPointer) -> UnsafeMutableRawPointer {
p
}
// Текущий указатель стека (приблизительно)
let sp = approximateSP(&x)
// Верхняя граница стека текущего потока
let top = pthread_get_stackaddr_np(pthread_self())
// Размер стека текущего потока
let size = pthread_get_stacksize_np(pthread_self())
// Нижняя граница стека
let bottom = top - size
// Определяем «безопасную» нижнюю границу: минимум 10 КБ или 10% от размера стека
let safetySize = min(10 * 1024, size / 10)
let safeBottom = bottom + safetySize
// Выводим диагностическую информацию
print("top : \(top)")
print("SP : \(sp) (approximate)")
print("safe : \(safeBottom) (relatively safe bottom)")
print("bottom : \(bottom)")
print("size : \(size)")
print("used : \(top - sp) [\(100 * (top - sp) / size)%]")
print("depth : \(depth)")
print()
// Проверяем, что стек не вышел за безопасную границу
precondition(sp > safeBottom && sp <= top, "stack is about to overflow \(sp - bottom) bytes left, depth: \(depth)")
}
/// Рекурсивно заполняет стек, вызывая себя с увеличивающейся глубиной,
/// и выделяя 1 КБ локального массива в каждом вызове.
/// Используется для тестирования глубины стека и защиты от переполнения.
///
/// - Parameter depth: Текущий уровень глубины рекурсии.
func fillStack(depth: Int) {
// Выделяем 1 КБ памяти в стеке
var localArray = [UInt8](repeating: 0, count: 1024)
// Используем массив, чтобы избежать оптимизаций компилятора
localArray[0] = UInt8(depth % 256)
// Проверяем текущее состояние стека
checkStack(depth: depth)
// Продолжаем рекурсию
fillStack(depth: depth + 1)
}
// Основной поток
if pthread_main_np() != 0 {
print("Thread.current.stackSize -> \(Thread.current.stackSize)")
print("pthread_get_stacksize_np -> \(pthread_get_stacksize_np(pthread_self()))")
print("getDefaultThreadStackSize -> \(getDefaultThreadStackSize())")
print("Check stack:")
// Проверка стека без рекурсии
checkStack(depth: 0)
// Запуск рекурсивного заполнения стека (опасно, может привести к крашу)
// fillStack(depth: 1)
}
Этот код, хотя и относительно краток, затрагивает множество важных аспектов работы со стеком и потоками в macOS / iOS. Далее мы подробно разберем каждую его часть.
Проверка состояния стека Link to heading
var x: UInt8 = 1
let sp = approximateSP(&x)
let top = pthread_get_stackaddr_np(pthread_self())
let size = pthread_get_stacksize_np(pthread_self())
let bottom = top - size
Вызов pthread_get_stackaddr_np возвращает верхнюю границу стека (stack base) для текущего потока. На платформах Darwin (macOS / iOS) стек растёт вниз, от верхнего адреса к нижнему, поэтому:
- top - это начальная точка выделенного системного блока памяти под стек потока; -именно от этого адреса начинается стек, и в процессе исполнения SP будет уменьшаться (двигаться вниз по адресам).
Таким образом, весь допустимый диапазон адресов для стека находится между bottom и top, а текущее положение SP (sp) должно находиться строго внутри этого диапазона. При выходе за границу bottom произойдёт stack overflow.
Добавляется safety buffer (safeBottom) - запас в 10% от размера стека:
let safetySize = min(10 * 1024, size / 10)
let safeBottom = bottom + safetySize
Это необходимо, чтобы избежать повреждений системной области при глубокой рекурсии.
Отображение состояния Link to heading
print("used : \(top - sp) [\(100*(top - sp)/size)%]")
Выводим, сколько байт стека уже использовано и в процентах.
Контроль переполнения Link to heading
precondition(sp > safeBottom && sp <= top, "stack is about to overflow \(sp - bottom) bytes left")
Если стек близок к bottom, программа аварийно завершается с понятным сообщением. Это помогает отловить потенциальные переполнения до их возникновения.
Рекурсивное заполнение стека Link to heading
func fillStack(depth: Int) {
var localArray = [UInt8](repeating: 0, count: 1024)
localArray[0] = UInt8(depth % 256)
checkStack(depth: depth)
fillStack(depth: depth + 1)
}
Каждый вызов функции использует ~1 КБ стека. Это позволяет постепенно заполнить стек и отследить момент, когда он почти переполнится.
Вывод программы на macOS Link to heading
Thread.current.stackSize -> 524288
pthread_get_stacksize_np() -> 8388608
getDefaultThreadStackSize() -> 524288
Check stack:
top : 0x00007ff7b6d59000
SP : 0x00007ff7b6d51de7 (approximate)
safe : 0x00007ff7b655b800 (relatively safe bottom)
bottom : 0x00007ff7b6559000
size : 8388608
used : 29209 [0%]
depth : 0
Суммируя. Так зачем использовать pthread_get_stacksize_np?
Link to heading
1. Узнать фактически выделенный стек текущему потоку Link to heading
Когда поток уже создан (в том числе главный поток), ни pthread_attr_t, ни Thread.stackSize из Foundation не дадут достоверную информацию о реально выделенной памяти под стек. pthread_get_stacksize_np возвращает точное значение, выделенное ядром под стек потока в момент его запуска.
Пример: Thread.main.stackSize на macOS возвращает 512 КБ, но реально стек у главного потока - 8 МБ. Только pthread_get_stacksize_np(pthread_self()) покажет это точно.
2. Оценка пределов глубокой рекурсии Link to heading
Если вы используете глубокую рекурсию (например, при обходе графов, деревьев, парсинге), важно знать предел, за которым стек закончится.
- Зная реальный размер стека и размер кадра функции, можно оценить максимально допустимую глубину рекурсии до переполнения.
- Это позволяет гарантировать устойчивость алгоритмов без крашей или неопределённого поведения.
3. Мониторинг использования и диагностика Stack Overflow Link to heading
При отладке или в рантайме можно вычислить, сколько стека уже использовано, и вовремя предупредить о переполнении, прежде чем произойдёт аварийное завершение. Таким образом, вы можете сравнивать текущее использование со всей выделенной областью.
4. Проверка нестандартных конфигураций Link to heading
Если вы создаёте потоки вручную через pthread_create и настраиваете размер стека через pthread_attr_setstacksize, то pthread_get_stacksize_np - это единственный способ проверить, был ли ваш атрибут действительно применён.
Это особенно важно:
- при тестировании платформенных ограничений;
- при создании потоков с уменьшенным стеком в embedded/RT приложениях;
- при проведении fuzzing-тестов на пределе ресурсов.
5. Поддержка мультиплатформенных библиотек Link to heading
Если вы пишете кросс-платформенную библиотеку на Swift или C/C++, и хотите реализовать диагностику или поведение, чувствительное к стеку, на Darwin (macOS, iOS), без этой функции не обойтись.
6. Осознанная защита от Stack Overflow Link to heading
Вы можете заранее проверить, достаточно ли осталось свободного стека до bottom, прежде чем выполнять стеково тяжёлую операцию, и если нет - отложить её или переключиться на другой поток.
Кратко: зачем pthread_get_stacksize_np
Link to heading
| Причина | Почему это важно |
|---|---|
| Получить точный размер стека | Foundation и pthread_attr могут показывать неверные данные |
| Анализ глубины вызовов | Для оценки, не наступит ли Stack Overflow |
| Диагностика в рантайме | Можно логгировать объём используемого стека |
| Проверка кастомных потоков | Убедиться, что вы выделили достаточно |
| Обработка граничных случаев | Для повышения отказоустойчивости |
Заключение Link to heading
В следующей статье мы рассмотрим, как использовать эту информацию для построения собственного рантайм-мониторинга и механизма раннего предупреждения о переполнении стека (stack overflow), дополняя его статистикой из первой части статьи.