Kernel space vs user space — ring 0 и ring 3
Когда ваш Python-скрипт запущен, в нём «уживаются» два мира: ваш код в user space (привилегии низкие) и ядро Linux в kernel space (привилегии полные). Эти два мира физически разделены на уровне процессора и памяти. Если бы их не было разделено — любая программа с багом могла бы испортить ядро, обнулить таблицу процессов, отформатировать диск.
В этом уроке разберём: что именно разделяет user и kernel, как процессор enforce’ит это разделение (через ring levels), почему memory virtualization без этого не работала бы, и как программа всё-таки попадает в ядро, когда нужно.
Зачем разделение
Представьте операционную систему без kernel/user разделения: любой процесс имеет полный доступ к памяти, к железу, к таблице процессов ОС, ко всем файлам. Что произойдёт?
- Любой баг — катастрофа. Сегфолт в браузере — система зависла. Один процесс читает чужие пароли из RAM. Программа случайно затёрла таблицу страниц — crash kernel.
- Любое вредоносное ПО — root. Открыли PDF с вирусом — он сразу имеет полный контроль над системой. Никаких защитных слоёв.
- Невозможно гарантировать ресурсы. Один процесс может занять весь CPU или всю память, и ОС не сможет это остановить.
ОС должна физически препятствовать программам делать опасные вещи. Не на уровне «давайте все договоримся», а на уровне «процессор откажется выполнять». Для этого нужна аппаратная поддержка.
Ring levels: модель защиты x86
Процессор x86 (и его наследник x86_64) имеет четыре уровня привилегий, называемых rings: ring 0, ring 1, ring 2, ring 3. Используются практически только два:
В реальности в Linux используются только ring 0 (kernel) и ring 3 (user). Между ними — пропасть. С точки зрения процессора, есть «kernel mode» и «user mode», и переход между ними строго контролируется.
Что может ring 3 (user mode)
Программа в user mode (ваш Python, браузер, что угодно) может:
- Читать/писать свою память (только страницы, которые ядро ей выделило).
- Выполнять обычные инструкции CPU (арифметика, jump’ы, вызовы функций).
- Делать syscall — специальная инструкция для перехода в kernel mode и обратно.
Что НЕ может ring 3
Запрещены привилегированные инструкции. На x86_64 это:
hlt— halt CPU. Если программа сможет выполнить — ядро встанет.cli/sti— disable/enable interrupts. Если запретить — системе крышка.in/out— прямое обращение к I/O портам железа.mov cr0/cr3/cr4— запись в control registers (управление виртуальной памятью, FPU и т.п.).lgdt/lidt— загрузка GDT/IDT (таблицы дескрипторов).wrmsr/rdmsr— запись/чтение Model-Specific Registers.
Если процесс в ring 3 попробует выполнить такую инструкцию — процессор сгенерирует исключение #GP (General Protection Fault). Ядро поймает это исключение и убьёт процесс. Это происходит физически, аппаратно. Программист не может «обойти» защиту — железо проверяет.
Memory protection: tag на каждой странице
Защита не ограничивается инструкциями. Виртуальная память тоже несёт информацию о привилегиях. На каждую страницу памяти процессор знает:
- Какие физические байты ей соответствуют (это виртуальная память — разбираем в M05).
- Какой минимальный privilege level требуется для доступа.
- Можно ли читать, писать, исполнять.
Если процесс в ring 3 пытается прочитать страницу, помеченную как kernel-only — процессор немедленно генерирует page fault. Ядро ловит, убивает процесс (SIGSEGV). Это означает: даже если ваш Python-процесс знает физический адрес какого-то ядерного буфера — он не сможет туда дотянуться. Аппаратная защита.
Это очень важный момент: сама память защищена, не только инструкции. Даже зная адрес, процесс не доберётся.
Address space: где живёт что
На 64-bit x86, виртуальное адресное пространство процесса — 256 TB. Половина — user space, половина — kernel space, но второе помечено как «доступно только в ring 0».
Когда процесс работает, его CPU видит обе половины (user и kernel) — но обращаться может только к user. Когда происходит syscall и процесс переходит в ring 0 — ядро может обращаться к kernel half. Это позволяет ядру быстро работать с памятью процесса (даже user pages видны kernel через ту же page table).
Можете увидеть распределение для вашего процесса:
cat /proc/self/maps
Вывод (сокращённо):
55c1a3d00000-55c1a3d04000 r--p 00000000 fd:00 1234567 /usr/bin/cat
55c1a3d04000-55c1a3d08000 r-xp 00004000 fd:00 1234567 /usr/bin/cat
55c1a3d08000-55c1a3d0b000 r--p 00008000 fd:00 1234567 /usr/bin/cat
55c1a3d0b000-55c1a3d0c000 r--p 0000a000 fd:00 1234567 /usr/bin/cat
55c1a3d0c000-55c1a3d0d000 rw-p 0000b000 fd:00 1234567 /usr/bin/cat
55c1a3e58000-55c1a3e79000 rw-p 00000000 00:00 0 [heap]
7fa1b8000000-7fa1b8024000 r--p 00000000 fd:00 7654321 /usr/lib/x86_64-linux-gnu/libc.so.6
7fa1b8024000-7fa1b81b9000 r-xp 00024000 fd:00 7654321 /usr/lib/x86_64-linux-gnu/libc.so.6
...
7ffd8f3e2000-7ffd8f404000 rw-p 00000000 00:00 0 [stack]
Все адреса — из user-half. Kernel-half (0xFFFF8000…) в выводе нет, потому что /proc/[pid]/maps показывает только user mappings.
Переход между мирами: syscalls
Если процесс в user mode не может делать привилегированных вещей — как он попадает в ядро, когда нужно прочитать файл или открыть сокет? Через системные вызовы (syscalls).
Что такое Linux: ядро, GNU и system callsSyscall — это контролируемая «дверь» из user space в kernel space. Программа выполняет специальную инструкцию (syscall на x86_64, или int 0x80 исторически), которая:
- Переключает CPU в ring 0.
- Передаёт управление в строго определённую точку входа в ядре.
- Ядро смотрит, какой syscall был запрошен, и выполняет его.
- Возвращает результат в регистр RAX.
- Переключает CPU обратно в ring 3, возвращает управление в user code.
В детали этого процесса погружаемся в M11 (Syscalls Deep). Здесь важно понять: переход управления строго контролируется.
Berkeley sockets API -- основа всех сетевых программ Программа не может «перейти в kernel mode» в произвольной точке — только через syscall с конкретным номером. Это значит, что ядро не может быть скомпрометировано подменой адреса перехода: процессор всегда зайдёт в определённую точку.
Сколько занимает context switch
Переход user -> kernel -> user стоит времени. Несколько микросекунд минимум.
- Сохранение регистров user процесса — ~20 регистров общего назначения + специальные.
- Переключение виртуальной памяти — если syscall вызывает schedule (контекстный переключатель), меняются page tables.
- TLB flush — буфер трансляции виртуальных адресов очищается. Следующие memory access будут медленнее, пока TLB не прогреется.
- Возврат — восстановление состояния, переключение в ring 3.
На современных CPU один syscall стоит порядка 100-500 нс (если не делает реальной работы). Простой getpid() — около 50 нс. Если syscall блокируется (например, ждёт диск) — это уже миллисекунды или больше.
Поэтому критические по производительности программы (БД, kernel-bypass network stacks типа DPDK) стараются минимизировать syscalls. vDSO (см. M11) — это оптимизация, которая выполняет «лёгкие» syscalls вообще без перехода в kernel.
Mode bit и аппаратные регистры
С точки зрения CPU, информация «мы сейчас в ring 0 или ring 3» хранится в специальных регистрах — сегментных, в первую очередь в CS (Code Segment). Два младших бита CS — это CPL (Current Privilege Level), значения 0 или 3.
При выполнении инструкции CPU смотрит на CPL и на permissions требуемой ресурс (страницы памяти, регистра, инструкции). Если CPL > требуемый уровень — исключение.
Программа в user mode не может изменить CS. Это можно только через специальные инструкции, и они доступны только из ring 0. То есть процесс не может «повысить себе привилегии» — только через syscall, где ядро решает, что делать.
# Если у вас есть С компилятор, попробуйте программу, которая
# пытается выполнить привилегированную инструкцию -- получите SIGSEGV/SIGILL:
cat > /tmp/priv.c << 'EOF'
int main() {
__asm__("cli"); // disable interrupts -- привилегированная
return 0;
}
EOF
gcc /tmp/priv.c -o /tmp/priv
/tmp/priv
echo "exit code: $?"
# Получите segmentation fault или illegal instruction
Что если ядру нужны данные из user space?
Допустим, программа делает write(fd, "hello", 5). Ядро должно прочитать строку «hello» из user памяти. Но user pages помечены как user-accessible — может ли ядро их читать в ring 0?
Да. Ring 0 — privileged. Ему доступна вся память: и kernel pages, и user pages. Однако в Linux есть дополнительная защита — даже ядро не должно случайно следовать пользовательскому указателю. Для этого есть специальные функции:
copy_from_user(dest, src, n)— читает из user memory с проверкой.copy_to_user(dest, src, n)— пишет в user memory с проверкой.get_user(var, ptr)/put_user(val, ptr)— для одного значения.
Проверка такая: указатель должен быть в user range (ниже kernel-границы) и страница должна быть mapped. Если процесс прислал bogus pointer — syscall вернёт -EFAULT, а не свалит ядро.
Это защита от ошибок и атак: например, в старых ядрах баг в драйвере мог позволить читать kernel memory через специально подобранный pointer от user (Spectre/Meltdown-like атаки — эту тему расширим в M11).
Попробуй сам
Несколько экспериментов:
# 1. Посмотреть, сколько времени занимает syscall:
time strace -c -e getpid bash -c 'for i in {1..10000}; do :; done' 2>&1 | tail
# Каждый getpid() -- порядка 100ns
# 2. Посмотреть на kernel и user CPU usage:
top -bn1 | head -3
# Строка %Cpu(s): X us, Y sy -- "us" это user mode, "sy" это kernel mode (system)
# 3. Сколько процентов вашего CPU за всё время прошло в kernel:
cat /proc/stat | head -1
# user nice system idle iowait irq softirq -- первые две колонки в user, system -- в kernel
# 4. Посмотреть, какие страницы в памяти вашего процесса:
cat /proc/self/maps | head -20
# Все адреса -- в user range (ниже 0x800000000000)
# 5. Узнать через strace, что делает простая программа в kernel:
strace -c true 2>&1 | tail
# В колонках time, calls, syscall -- что вызывалось в kernel и сколько раз