Привет, Хабр!

Загружаемые модули появились в Linux в начале девяностых по очень практической причине: пересобирать и перезагружать ядро ради каждого драйвера никто не хотел. Сегодня в виде .ko живёт почти вся периферия, файловые системы, криптография, сетевые фильтры, и писать модули приходится не только людям из мира BSD, но и embedded‑инженерам, разработчикам железа и безопасникам.

Цена ошибки тут другая, чем в userspace. Упавшая программа не трогает соседей: ядро её пристрелит, и жизнь продолжится. Модуль работает внутри ядра, поэтому его утечка памяти становится утечкой всей системы, его залипшая блокировка вешает процессор целиком, а разыменование кривого указателя даёт oops со странным стеком в dmesg или сразу panic и ребут.

Хуже всего то, что почти ничего из этого не всплывает сразу. Модуль собирается без единого предупреждения, insmod возвращает ноль, lsmod его показывает, первые минуты всё выглядит здоровым. Ломается позже: под нагрузкой, на определённой последовательности событий, на машине с числом ядер побольше вашей тестовой. В статье пять таких историй.

Счётчик, который считает неправильно при двадцати читателях

Символьное устройство, отдающее номер обращения. Логика в две строки.

static unsigned long counter = 0;

static ssize_t counter_read(struct file *filp, char __user *buf,
                            size_t count, loff_t *offset)
{
    char kbuf[32];
    int len;

    if (*offset > 0)
        return 0;

    counter++;
    len = snprintf(kbuf, sizeof(kbuf), "%lu\n", counter);

    if (copy_to_user(buf, kbuf, len))
        return -EFAULT;

    *offset += len;
    return len;
}

cat /dev/counter возвращает единицу, потом двойку, всё честно. Запускаете двадцать параллельных читателей через stress‑ng — и счётчик начинает прыгать через значение, а иногда трижды подряд отдаёт одно и то же.

counter++ не атомарен. В ассемблере это три инструкции: прочитать, прибавить, записать. Два процессора спокойно читают одно и то же значение, каждый прибавляет свою единицу и записывает. Два инкремента, счётчик вырос на один. Классическая гонка, и в ядре она вылезает мгновенно, потому что параллелизм там настоящий, а не кооперативный.

Для счётчика хватает атомарного типа:

static atomic_long_t counter = ATOMIC_LONG_INIT(0);

    long value = atomic_long_inc_return(&counter);
    len = snprintf(kbuf, sizeof(kbuf), "%ld\n", value);

atomic_long_inc_return прибавляет и возвращает новое значение одной неделимой операцией, так что каждому читателю достаётся свой номер.

Для чего‑то сложнее счётчика — списка, дерева, таблицы — атомиков уже мало, и приходится выбирать блокировку под контекст. Spinlock, если критическая секция короткая и код не имеет права спать. Mutex, если внутри может произойти сон. RCU, если чтений много, а записей мало и они редки. Выбор здесь не про вкус, а про то, в каком контексте выполняется код, и следующая история будет об этом.

GFP_KERNEL в обработчике прерывания

Обработчик забирает данные с устройства и кладёт их в очередь.

static void data_ready_handler(void *dev_data)
{
    struct sensor_data *device = dev_data;
    struct data_entry *entry;

    entry = kmalloc(sizeof(*entry), GFP_KERNEL);
    if (!entry)
        return;

    entry->value = readq(device->mmio + REG_DATA);
    entry->timestamp = ktime_get();

    list_add_tail(&entry->list, &device->queue);
    wake_up(&device->wait);
}

На стенде с редкими прерываниями всё прекрасно. На нагрузочном тесте со ста тысячами прерываний в секунду через несколько минут прилетает BUG: scheduling while atomic или система просто встаёт.

GFP_KERNEL разрешает аллокатору спать. Если свободных страниц нет, он пойдёт сбрасывать кеши, будить kswapd, в пределе звать OOM‑killer, и на любом из этих шагов может вызвать schedule(). Обработчик прерывания спать не имеет права: он должен отработать и вернуть управление, планировщик его не переключит. Попытка заснуть в этом контексте в лучшем случае даёт ругань в dmesg, в худшем — вешает машину.

Минимальная правка — сменить флаг:

entry = kmalloc(sizeof(*entry), GFP_ATOMIC);

GFP_ATOMIC берёт память только из уже свободных страниц, не спит и не запускает освобождение. Расплата в том, что он гораздо охотнее возвращает NULL, поэтому проверка результата перестаёт быть формальностью: при памяти под завязку ваш обработчик начнёт терять события, и это надо хотя бы считать.

Но правильнее обычно другое. Если в обработчике вообще приходится выделять память, стоит спросить, почему он делает так много. Типовые решения: заранее выделенный пул или kmem_cache, кольцевой буфер kfifo без аллокаций вовсе, или перенос всей тяжёлой работы в поток через request_threaded_irq(). В нижней половине, которая живёт в потоке, спать уже можно, и там GFP_KERNEL вполне законен. Верхняя половина при этом делает минимум: подтвердила прерывание, разбудила поток, вышла.

Общее правило звучит так: сначала определите контекст (hardirq, softirq, tasklet, workqueue, syscall), потом выбирайте флаг. Ошибиться легко, поэтому в отладочной сборке помогает might_sleep() в подозрительных функциях: он громко ругнётся, если функцию позвали оттуда, откуда нельзя.

Mutex внутри spinlock

Таблица устройств под spinlock, а отдельные поля защищены собственным мьютексом.

spin_lock_irqsave(&table_lock, lock_flags);

entry = &devices[dev_id];

mutex_lock(&entry->config_lock);      /* вот здесь */
entry->flags = new_flags;
mutex_unlock(&entry->config_lock);

spin_unlock_irqrestore(&table_lock, lock_flags);

С CONFIG_DEBUG_ATOMIC_SLEEP=y тесты падают с BUG: sleeping function called from invalid context. В боевой конфигурации без отладки никакой ошибки нет, зато система иногда подвисает под нагрузкой, и связать это с данным кодом почти невозможно.

Причина та же, что в предыдущей истории, только замаскированная. Взятый spinlock выключает вытеснение на этом процессоре: пока вы его держите, планировщик вас не тронет. Поэтому spinlock дёшев и именно поэтому под ним нельзя спать. А mutex_lock спит: если мьютекс занят, задача уходит в сон до его освобождения. Внутри там schedule(), который в атомарном контексте делать нечего.

Разбираться надо не с симптомом, а с тем, зачем в коде появились две блокировки. Если мьютекс защищает что‑то долгое, spinlock нужно снять раньше, удержав объект от исчезновения счётчиком ссылок:

spin_lock(&table_lock);
entry = &devices[dev_id];
kref_get(&entry->refcount);        /* чтобы entry не умер после unlock */
spin_unlock(&table_lock);

mutex_lock(&entry->config_lock);
entry->flags = new_flags;
mutex_unlock(&entry->config_lock);

kref_put(&entry->refcount, device_release);

А если под мьютексом делается ровно одно присваивание, как в исходном коде, никакого мьютекса там быть не должно: spinlock_t config_lock решает задачу без сна и без противоречия.

Со вложенными блокировками есть и вторая беда — дедлок из‑за разного порядка захвата в разных функциях. Ловится он не глазами, а CONFIG_PROVE_LOCKING=y: lockdep строит граф порядка захвата и ругается на потенциальный цикл ещё до того, как он случился на живой машине.

ioctl, который верит пользователю

Настройка устройства через ioctl.

struct device_config {
    int mode;
    int channel;
    char label[64];
};

static long device_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct device_config config;

    switch (cmd) {
    case DEVICE_SET_CONFIG:
        if (copy_from_user(&config, (void __user *)arg, sizeof(config)))
            return -EFAULT;
        apply_config(&config);
        return 0;
    }
    return -EINVAL;
}

Тестовая программа передаёт корректную структуру, всё работает. Аудит безопасности приносит длинный список претензий.

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

  • Первое: кто угодно может дёрнуть этот ioctl. Если устройство управляет железом, проверка прав обязательна, и это одна строка с capable(CAP_SYS_ADMIN) (или более узкая capability, если она подходит по смыслу).

  • Второе: label приезжает из userspace целиком, все шестьдесят четыре байта. Пользователь имеет полное право не класть туда нулевой байт. Дальше apply_config зовёт printk("%s", config.label) или strcpy, и они читают за границей структуры, по стеку ядра.

  • Третье: числовые поля не проверяются вообще. mode, пришедший от пользователя, спокойно уедет индексом в массив.

static long device_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
    struct device_config config;

    if (!capable(CAP_SYS_ADMIN))
        return -EPERM;

    switch (cmd) {
    case DEVICE_SET_CONFIG:
        if (copy_from_user(&config, (void __user *)arg, sizeof(config)))
            return -EFAULT;

        config.label[sizeof(config.label) - 1] = '\0';   /* строка теперь строка */

        if (config.mode < 0 || config.mode > MAX_MODE)
            return -EINVAL;
        if (config.channel < 0 || config.channel > MAX_CHANNEL)
            return -EINVAL;

        return apply_config(&config);
    }
    return -EINVAL;
}

Ещё пара привычек, которые стоит завести сразу. Строки в ядре копируются через strscpy, а не strcpy и не strncpy (второй не гарантирует терминатор). Размер копируемого блока задаётся константой, а не числом, пришедшим от пользователя; если длина всё‑таки переменная, её проверяют на разумный максимум до всякого выделения памяти.

И структуру, скопированную из userspace, нельзя перечитывать повторно: пользователь в другом потоке успеет её поменять между двумя copy_from_user, и вы получите классический TOCTOU. Копируем один раз, работаем с ядерной копией.

Инициализация, которая забыла прибрать за собой

Модуль поднимает ресурсы по цепочке, и автор аккуратно проверяет каждый шаг.

static int __init driver_init(void)
{
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, 1, "mydev");
    if (ret)
        return ret;

    cdev_init(&mydev_cdev, &mydev_fops);
    ret = cdev_add(&mydev_cdev, dev_num, 1);
    if (ret)
        return ret;               /* а номер устройства так и остался занят */

    mydev_class = class_create("mydev");
    if (IS_ERR(mydev_class))
        return PTR_ERR(mydev_class);
    ...
}

Первая загрузка проходит. Дальше разработчик проверяет обработку ошибок: заставляет cdev_add вернуть ‑ENOMEM, модуль ожидаемо не грузится, но зарезервированный major/minor остаётся занятым навсегда, до перезагрузки. Каждая неудачная попытка insmod отъедает ещё один номер.

Проверки‑то есть, а отката нет. В ядре для этого используется цепочка меток и goto, и это не признак дурного тона, а прямо рекомендованная в coding-style идиома. Ранние return в такой функции гарантированно приводят либо к утечке, либо к лесенке из вложенных if на пять уровней.

static int __init driver_init(void)
{
    int ret;

    ret = alloc_chrdev_region(&dev_num, 0, 1, "mydev");
    if (ret)
        return ret;

    cdev_init(&mydev_cdev, &mydev_fops);
    ret = cdev_add(&mydev_cdev, dev_num, 1);
    if (ret)
        goto err_cdev;

    mydev_class = class_create("mydev");
    if (IS_ERR(mydev_class)) {
        ret = PTR_ERR(mydev_class);
        goto err_class;
    }

    mydev_device = device_create(mydev_class, NULL, dev_num, NULL, "mydev");
    if (IS_ERR(mydev_device)) {
        ret = PTR_ERR(mydev_device);
        goto err_device;
    }

    return 0;

err_device:
    class_destroy(mydev_class);
err_class:
    cdev_del(&mydev_cdev);
err_cdev:
    unregister_chrdev_region(dev_num, 1);
    return ret;
}

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

Начиная с ядра 6.4 у class_create больше нет аргумента с владельцем модуля, и старый код с THIS_MODULE просто не соберётся — на форумах и в старых статьях этот вызов до сих пор встречается в прежнем виде. А cdev_add плюс device_create в современных драйверах чаще заменяют на cdev_device_add, который делает то же самое одним вызовом и с одной точкой отката.

Там, где у вас есть struct device, львиную долю этой бухгалтерии снимает devres: devm_kmalloc, devm_ioremap, devm_request_irq привязывают освобождение к устройству, и при его удалении всё отпускается само. Половина меток из примера выше в таком коде просто исчезает.

Что включить перед тем, как нести модуль на рабочую машину

Компилятор проверит типы, но ни один компилятор не скажет вам, что эта функция спит, а вызывают её из‑под spinlock. Всё интересное здесь — семантика использования API ядра, и ловится она отладочными опциями.

Проверить, насколько хорошо вы ориентируетесь в контекстах ядра, блокировках и работе с памятью, можно в бесплатном вступительном тесте по разработке ядра Linux. Он покажет текущий уровень и темы, в которых остались пробелы.

Минимальный набор для тестового ядра: CONFIG_DEBUG_ATOMIC_SLEEP=y ловит сон в атомарном контексте (истории со второй и третьей), CONFIG_PROVE_LOCKING=y строит граф блокировок и находит дедлоки до того, как они случатся, CONFIG_KASAN=y показывает use‑after‑free и выход за границы буфера с внятным стеком. Медленно, зато честно.

Гонять модуль надо под нагрузкой и в параллель, потому что первая история на одном читателе не воспроизводится никогда. Отдельно стоит десятки раз подряд загрузить и выгрузить модуль: утечки, невидимые за один цикл, за двадцать циклов вылезают наружу счётчиком занятых ресурсов и руганью в dmesg.

И checkpatch.pl из дерева ядра, разумеется. Он не заменит ревью, но выловит половину глупостей раньше, чем это сделает кто‑то другой.

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

  • 3 августа, 20:00. «Что такое модуль ядра. Как его написать, собрать, запустить». Записаться

  • 10 августа, 20:00. «Вход в ядро: системные вызовы и граница между user space и kernel space». Записаться

  • 20 августа, 20:00. «Средства защиты в ядре Linux». Записаться

Больше бесплатных уроков июля смотрите в дайджесте.

Комментарии (0)