В предыдущей статье мы ввели многозадачность. Сегодня пришло время рассмотреть тему драйверов символьных устройств.
Конкретно сегодня мы напишем драйвер терминала, механизм отложенной обработки прерываний, рассмотрим тему обработчиков верхних и нижних половин прерываний.
Начнем с создания структуры устройства, затем введем базовую поддержку файлового ввода-вывода, рассмотрим структуру io_buf и функции для работы с файлами из stdio.h.
Система сборки (make, gcc, gas). Первоначальная загрузка (multiboot). Запуск (qemu). Библиотека C (strcpy, memcpy, strext). Библиотека C (sprintf, strcpy, strcmp, strtok, va_list ...). Сборка библиотеки в режиме ядра и в режиме пользовательского приложения. Системный журнал ядра. Видеопамять. Вывод на терминал (kprintf, kpanic, kassert). Динамическая память, куча (kmalloc, kfree). Организация памяти и обработка прерываний (GDT, IDT, PIC, syscall). Исключения. Виртуальная память (каталог страниц и таблица страниц). Процесс. Планировщик. Многозадачность. Системные вызовы (kill, exit, ps).
Драйверы символьных устройств. Системные вызовы (ioctl, fopen, fread, fwrite). Библиотека C (fopen, fclose, fprintf, fscanf).
Файловая система ядра (initrd), elf и его внутренности. Системные вызовы (exec). Оболочка как полноценная программа для ядра. Пользовательский режим защиты (ring3). Сегмент состояния задачи (tss).
Начинается все с об]явления символьного устройства. Как ты помнишь, в Linux Device Drivers определение устройства выглядело так:
Основной смысл в том, чтобы назначить устройству реализации функций файлового ввода-вывода.
Мы обойдемся одной структурой, но смысл будет похожий:
Каждому устройству соответствует половин список прерываний, вызываемых в момент генерации прерываний.
В Linux такие половины называются верхними, у нас наоборот, нижними (более низкий уровень).
Лично мне это показалось более логичным и я ненароком запомнил термины наоборот. Каждый элемент списка нижних половин прерываний опишем так:
При инициализации драйвер будет регистрировать свое устройство через функцию dev_register, другими словами добавлять новое устройство в кольцевой список:
Чтобы все это хоть как-то заработало нам понадобится зачаток файловой системы. На первых порах, файлы у нас будут только для символьных устройств.
Т.е. открытие файла будет эквивалентно созданию структуры FILE из stdio для соответствующего файлу драйвера.
При этом, имена файлов будут совпадать с именем устройств. Определим понятие файлового дескриптора в нашей библиотеки C (stdio.h).
Для простоты пусть пока все открытые файлы будут храниться в кольцевом списке. Элемент списка опишем так:
Для каждого открытого файла мы будем хранить ссылку на устройство. Реализуем кольцевой список открытых файлов и реализуем системные вызовы read/write/ioctl.
При открытии файла нам нужно просто структуре io_buf_t присвоить начальные позиции буферов чтения и записи из драйвера, ну и соответственно связать файловые операции с драйвером устройства.
Файловые операции read/write/ioctl определим по одному шаблону на примере системного вызова read.
Сами системные вызовы которые мы научились писать в прошлом уроке будут просто вызывать эти функции.
Короче говоря они просто будут дергать коллбэки из определения устройства. Теперь напишем драйвер терминала.
Нам понадобится буфер вывода на экран и буфер ввода с клавиатуры, а также пару флагов для режимов ввода и вывода.
Напишем функцию создания устройства. Она просто проставляет коллбэки файловых операций и обработчики нижних половин прерываний, после чего регистрирует устройство в кольцевом списке.
Нижний обработчик половин прерываний для клавиатуры определим так:
Тут мы просто кладем введенный символ в буффер клавиатуры. В конце регистрируем отложенный вызов обработчика верхних половин прерываний клавиатуры. Делается это посылкой сообщения (IPC) потоку ядра.
Сам же поток ядра довольно прост:
С помощью него будет вызываться обработчик верхних половин прерываний клавиатуры. Его целью является дублирование символа на экран через копирование буфера вывода в видеопамять.
Теперь осталось написать сами функции ввода-вывода, вызываемые из файловых операций.
Посимвольные операции не сильно сложнее и в комментировании думаю не нуждаются.
Осталось только для управления режимами ввода и вывода реализовать ioctl.
Теперь реализуем файловый ввод вывод на уровне нашей библиотеки С.
Ну и немного высокоуровневых функций приведу тут:
Чтобы пока не морочиться с форматным чтением, будем всегда просто читать в строку как будто дан флаг %s. Мне было лень вводить новый статус задачи для ожидания файловых дескрипторов, поэтому просто в бесконечном цикле пытаемся считать что-нибудь пока нам это не удасться.
На этом все. Теперь ты можешь смело прикручивать драйвера к своему ядру!
Смотри видеоурок для дополнительной информации.
> Исходный код в git репозиторий (тебе нужна ветка lesson8)
Конкретно сегодня мы напишем драйвер терминала, механизм отложенной обработки прерываний, рассмотрим тему обработчиков верхних и нижних половин прерываний.
Начнем с создания структуры устройства, затем введем базовую поддержку файлового ввода-вывода, рассмотрим структуру io_buf и функции для работы с файлами из stdio.h.
Оглавление
Система сборки (make, gcc, gas). Первоначальная загрузка (multiboot). Запуск (qemu). Библиотека C (strcpy, memcpy, strext). Библиотека C (sprintf, strcpy, strcmp, strtok, va_list ...). Сборка библиотеки в режиме ядра и в режиме пользовательского приложения. Системный журнал ядра. Видеопамять. Вывод на терминал (kprintf, kpanic, kassert). Динамическая память, куча (kmalloc, kfree). Организация памяти и обработка прерываний (GDT, IDT, PIC, syscall). Исключения. Виртуальная память (каталог страниц и таблица страниц). Процесс. Планировщик. Многозадачность. Системные вызовы (kill, exit, ps).
Драйверы символьных устройств. Системные вызовы (ioctl, fopen, fread, fwrite). Библиотека C (fopen, fclose, fprintf, fscanf).
Файловая система ядра (initrd), elf и его внутренности. Системные вызовы (exec). Оболочка как полноценная программа для ядра. Пользовательский режим защиты (ring3). Сегмент состояния задачи (tss).
Драйверы символьных устройств
Начинается все с об]явления символьного устройства. Как ты помнишь, в Linux Device Drivers определение устройства выглядело так:
struct cdev *my_cdev = cdev_alloc( );
my_cdev->ops = &my_fops;
Основной смысл в том, чтобы назначить устройству реализации функций файлового ввода-вывода.
Мы обойдемся одной структурой, но смысл будет похожий:
extern struct dev_t {
struct clist_head_t list_head; /* should be at first */
char name[8]; /* device name */
void* base_r; /* base read address */
void* base_w; /* base write address */
dev_read_cb_t read_cb; /* read handler */
dev_write_cb_t write_cb; /* write handler */
dev_ioctl_cb_t ioctl_cb; /* device specific command handler */
struct clist_definition_t ih_list; /* low half interrupt handlers */
};
Каждому устройству соответствует половин список прерываний, вызываемых в момент генерации прерываний.
В Linux такие половины называются верхними, у нас наоборот, нижними (более низкий уровень).
Лично мне это показалось более логичным и я ненароком запомнил термины наоборот. Каждый элемент списка нижних половин прерываний опишем так:
extern struct ih_low_t {
struct clist_head_t list_head; /* should be at first */
int number; /* interrupt number */
ih_low_cb_t handler; /* interrupt handler */
};
При инициализации драйвер будет регистрировать свое устройство через функцию dev_register, другими словами добавлять новое устройство в кольцевой список:
extern void dev_register(struct dev_t* dev)
{
struct clist_head_t* entry;
struct dev_t* device;
/* create list entry */
entry = clist_insert_entry_after(&dev_list, dev_list.head);
device = (struct dev_t*)entry->data;
/* fill data */
strncpy(device->name, dev->name, sizeof(dev->name));
device->base_r = dev->base_r;
device->base_w = dev->base_w;
device->read_cb = dev->read_cb;
device->write_cb = dev->write_cb;
device->ioctl_cb = dev->ioctl_cb;
device->ih_list.head = dev->ih_list.head;
device->ih_list.slot_size = dev->ih_list.slot_size;
}
Чтобы все это хоть как-то заработало нам понадобится зачаток файловой системы. На первых порах, файлы у нас будут только для символьных устройств.
Т.е. открытие файла будет эквивалентно созданию структуры FILE из stdio для соответствующего файлу драйвера.
При этом, имена файлов будут совпадать с именем устройств. Определим понятие файлового дескриптора в нашей библиотеки C (stdio.h).
struct io_buf_t {
int fd; /* file descriptor */
char* base; /* buffer beginning */
char* ptr; /* position in buffer */
bool is_eof; /* whether end of file */
void* file; /* file definition */
};
#define FILE struct io_buf_t
Для простоты пусть пока все открытые файлы будут храниться в кольцевом списке. Элемент списка опишем так:
extern struct file_t {
struct clist_head_t list_head; /* should be at first */
struct io_buf_t io_buf; /* file handler */
char name[8]; /* file name */
int mod_rw; /* whether read or write */
struct dev_t* dev; /* whether device driver */
};
Для каждого открытого файла мы будем хранить ссылку на устройство. Реализуем кольцевой список открытых файлов и реализуем системные вызовы read/write/ioctl.
При открытии файла нам нужно просто структуре io_buf_t присвоить начальные позиции буферов чтения и записи из драйвера, ну и соответственно связать файловые операции с драйвером устройства.
extern struct io_buf_t* file_open(char* path, int mod_rw)
{
struct clist_head_t* entry;
struct file_t* file;
struct dev_t* dev;
/* try to find already opened file */
entry = clist_find(&file_list, file_list_by_name_detector, path, mod_rw);
file = (struct file_t*)entry->data;
if (entry != null) {
return &file->io_buf;
}
/* create list entry */
entry = clist_insert_entry_after(&file_list, file_list.head);
file = (struct file_t*)entry->data;
/* whether file is device */
dev = dev_find_by_name(path);
if (dev != null) {
/* device */
file->dev = dev;
if (mod_rw == MOD_R) {
file->io_buf.base = dev->base_r;
} else if (mod_rw == MOD_W) {
file->io_buf.base = dev->base_w;
}
} else {
/* fs node */
file->dev = null;
unreachable(); /* fs in not implemented yet */
}
/* fill data */
file->mod_rw = mod_rw;
file->io_buf.fd = next_fd++;
file->io_buf.ptr = file->io_buf.base;
file->io_buf.is_eof = false;
file->io_buf.file = file;
strncpy(file->name, path, sizeof(file->name));
return &file->io_buf;
}
Файловые операции read/write/ioctl определим по одному шаблону на примере системного вызова read.
Сами системные вызовы которые мы научились писать в прошлом уроке будут просто вызывать эти функции.
extern size_t file_read(struct io_buf_t* io_buf, char* buff, u_int size)
{
struct file_t* file;
file = (struct file_t*)io_buf->file;
/* whether file is device */
if (file->dev != null) {
/* device */
return file->dev->read_cb(&file->io_buf, buff, size);
} else {
/* fs node */
unreachable(); /* fs in not implemented yet */
}
return 0;
}
Короче говоря они просто будут дергать коллбэки из определения устройства. Теперь напишем драйвер терминала.
Драйвер терминала
Нам понадобится буфер вывода на экран и буфер ввода с клавиатуры, а также пару флагов для режимов ввода и вывода.
static const char* tty_dev_name = TTY_DEV_NAME; /* teletype device name */
static char tty_output_buff[VIDEO_SCREEN_SIZE]; /* teletype output buffer */
static char tty_input_buff[VIDEO_SCREEN_WIDTH]; /* teletype input buffer */
char* tty_output_buff_ptr = tty_output_buff;
char* tty_input_buff_ptr = tty_input_buff;
bool read_line_mode = false; /* whether read only whole line */
bool is_echo = false; /* whether to put readed symbol to stdout */
Напишем функцию создания устройства. Она просто проставляет коллбэки файловых операций и обработчики нижних половин прерываний, после чего регистрирует устройство в кольцевом списке.
extern void tty_init()
{
struct clist_head_t* entry;
struct dev_t dev;
struct ih_low_t* ih_low;
memset(tty_output_buff, 0, sizeof(VIDEO_SCREEN_SIZE));
memset(tty_input_buff, 0, sizeof(VIDEO_SCREEN_WIDTH));
/* register teletype device */
strcpy(dev.name, tty_dev_name);
dev.base_r = tty_input_buff;
dev.base_w = tty_output_buff;
dev.read_cb = tty_read;
dev.write_cb = tty_write;
dev.ioctl_cb = tty_ioctl;
dev.ih_list.head = null;
/* add interrupt handlers */
dev.ih_list.slot_size = sizeof(struct ih_low_t);
entry = clist_insert_entry_after(&dev.ih_list, dev.ih_list.head);
ih_low = (struct ih_low_t*)entry->data;
ih_low->number = INT_KEYBOARD;
ih_low->handler = tty_keyboard_ih_low;
dev_register(&dev);
}
Нижний обработчик половин прерываний для клавиатуры определим так:
/*
* Key press low half handler
*/
static void tty_keyboard_ih_low(int number, struct ih_low_data_t* data)
{
/* write character to input buffer */
char* keycode = data->data;
int index = *keycode;
assert(index < 128);
char ch = keyboard_map[index];
*tty_input_buff_ptr++ = ch;
if (is_echo && ch != '\n') {
/* echo character to screen */
*tty_output_buff_ptr++ = ch;
}
/* register deffered execution */
struct message_t msg;
msg.type = IPC_MSG_TYPE_DQ_SCHED;
msg.len = 4;
*((size_t *)msg.data) = (size_t)tty_keyboard_ih_high;
ksend(TID_DQ, &msg);
}
Тут мы просто кладем введенный символ в буффер клавиатуры. В конце регистрируем отложенный вызов обработчика верхних половин прерываний клавиатуры. Делается это посылкой сообщения (IPC) потоку ядра.
Сам же поток ядра довольно прост:
/*
* Deferred queue execution scheduler
* This task running in kernel mode
*/
void dq_task() {
struct message_t msg;
for (;;) {
kreceive(TID_DQ, &msg);
switch (msg.type) {
case IPC_MSG_TYPE_DQ_SCHED:
/* do deffered callback execution */
assert(msg.len == 4);
dq_handler_t handler = (dq_handler_t)*((size_t*)msg.data);
assert((size_t)handler < KERNEL_CODE_END_ADDR);
printf(MSG_DQ_SCHED, handler);
handler(msg);
break;
}
}
exit(0);
}
С помощью него будет вызываться обработчик верхних половин прерываний клавиатуры. Его целью является дублирование символа на экран через копирование буфера вывода в видеопамять.
/*
* Key press high half handler
*/
static void tty_keyboard_ih_high(struct message_t *msg)
{
video_flush(tty_output_buff);
}
Теперь осталось написать сами функции ввода-вывода, вызываемые из файловых операций.
/*
* Read line from tty to string
*/
static u_int tty_read(struct io_buf_t* io_buf, void* buffer, u_int size)
{
char* ptr = buffer;
assert((size_t)io_buf->ptr <= (size_t)tty_input_buff_ptr);
assert((size_t)tty_input_buff_ptr >= (size_t)tty_input_buff);
assert(size > 0);
io_buf->is_eof = (size_t)io_buf->ptr == (size_t)tty_input_buff_ptr;
if (read_line_mode) {
io_buf->is_eof = !strchr(io_buf->ptr, '\n');
}
for (int i = 0; i < size - 1 && !io_buf->is_eof; ++i) {
char ch = tty_read_ch(io_buf);
*ptr++ = ch;
if (read_line_mode && ch == '\n') {
break;
}
}
return (size_t)ptr - (size_t)buffer;
}
/*
* Write to tty
*/
static void tty_write(struct io_buf_t* io_buf, void* data, u_int size)
{
char* ptr = data;
for (int i = 0; i < size && !io_buf->is_eof; ++i) {
tty_write_ch(io_buf, *ptr++);
}
}
Посимвольные операции не сильно сложнее и в комментировании думаю не нуждаются.
/*
* Write single character to tty
*/
static void tty_write_ch(struct io_buf_t* io_buf, char ch)
{
if ((size_t)tty_output_buff_ptr - (size_t)tty_output_buff + 1 < VIDEO_SCREEN_SIZE) {
if (ch != '\n') {
/* regular character */
*tty_output_buff_ptr++ = ch;
} else {
/* new line character */
int line_pos = ((size_t)tty_output_buff_ptr - (size_t)tty_output_buff) % VIDEO_SCREEN_WIDTH;
for (int j = 0; j < VIDEO_SCREEN_WIDTH - line_pos; ++j) {
*tty_output_buff_ptr++ = ' ';
}
}
} else {
tty_output_buff_ptr = video_scroll(tty_output_buff, tty_output_buff_ptr);
tty_write_ch(io_buf, ch);
}
io_buf->ptr = tty_output_buff_ptr;
}
/*
* Read single character from tty
*/
static char tty_read_ch(struct io_buf_t* io_buf)
{
if ((size_t)io_buf->ptr < (size_t)tty_input_buff_ptr) {
return *io_buf->ptr++;
} else {
io_buf->is_eof = true;
return '\0';
}
}
Осталось только для управления режимами ввода и вывода реализовать ioctl.
/*
* Teletype specific command
*/
static void tty_ioctl(struct io_buf_t* io_buf, int command)
{
char* hello_msg = MSG_KERNEL_NAME;
switch (command) {
case IOCTL_INIT: /* prepare video device */
if (io_buf->base == tty_output_buff) {
kmode(false); /* detach syslog from screen */
tty_output_buff_ptr = video_clear(io_buf->base);
io_buf->ptr = tty_output_buff_ptr;
tty_write(io_buf, hello_msg, strlen(hello_msg));
video_flush(io_buf->base);
io_buf->ptr = tty_output_buff_ptr;
} else if (io_buf->base == tty_input_buff) {
unreachable();
}
break;
case IOCTL_CLEAR:
if (io_buf->base == tty_output_buff) {
/* fill output buffer with spaces */
tty_output_buff_ptr = video_clear(io_buf->base);
video_flush(io_buf->base);
io_buf->ptr = tty_output_buff_ptr;
} else if (io_buf->base == tty_input_buff) {
/* clear input buffer */
tty_input_buff_ptr = tty_input_buff;
io_buf->ptr = io_buf->base;
io_buf->is_eof = true;
}
break;
case IOCTL_FLUSH: /* flush buffer to screen */
if (io_buf->base == tty_output_buff) {
video_flush(io_buf->base);
} else if (io_buf->base == tty_input_buff) {
unreachable();
}
break;
case IOCTL_READ_MODE_LINE: /* read only whole line */
if (io_buf->base == tty_input_buff) {
read_line_mode = true;
} else if (io_buf->base == tty_output_buff) {
unreachable();
}
break;
case IOCTL_READ_MODE_ECHO: /* put readed symbol to stdout */
if (io_buf->base == tty_input_buff) {
is_echo = true;
} else if (io_buf->base == tty_output_buff) {
unreachable();
}
break;
default:
unreachable();
}
}
Теперь реализуем файловый ввод вывод на уровне нашей библиотеки С.
/*
* Api - Open file
*/
extern FILE* fopen(const char* file, int mod_rw)
{
FILE* result = null;
asm_syscall(SYSCALL_OPEN, file, mod_rw, &result);
return result;
}
/*
* Api - Close file
*/
extern void fclose(FILE* file)
{
asm_syscall(SYSCALL_CLOSE, file);
}
/*
* Api - Read from file to buffer
*/
extern u_int fread(FILE* file, char* buff, u_int size)
{
return asm_syscall(SYSCALL_READ, file, buff, size);
}
/*
* Api - Write data to file
*/
extern void fwrite(FILE* file, const char* data, u_int size)
{
asm_syscall(SYSCALL_WRITE, file, data, size);
}
Ну и немного высокоуровневых функций приведу тут:
/*
* Api - Print user message
*/
extern void uvnprintf(const char* format, u_int n, va_list list)
{
char buff[VIDEO_SCREEN_WIDTH];
vsnprintf(buff, n, format, list);
uputs(buff);
}
/*
* Api - Read from file to string
*/
extern void uscanf(char* buff, ...)
{
u_int readed = 0;
do {
readed = fread(stdin, buff, 255);
} while (readed == 0);
buff[readed - 1] = '\0'; /* erase new line character */
uprintf("\n");
uflush();
}
Чтобы пока не морочиться с форматным чтением, будем всегда просто читать в строку как будто дан флаг %s. Мне было лень вводить новый статус задачи для ожидания файловых дескрипторов, поэтому просто в бесконечном цикле пытаемся считать что-нибудь пока нам это не удасться.
На этом все. Теперь ты можешь смело прикручивать драйвера к своему ядру!
Ссылки
Смотри видеоурок для дополнительной информации.
> Исходный код в git репозиторий (тебе нужна ветка lesson8)
Список литературы
- James Molloy. Roll your own toy UNIX-clone OS.
- Зубков. Ассемблер для DOS, Windows, Unix
- Калашников. Ассемблер — это просто!
- Таненбаум. Операционные системы. Реализация и разработка.
- Роберт Лав. Ядро Linux. Описание процесса разработки.
Комментарии (3)
assembled
25.09.2019 10:19
Вы обращаетесь к данным по указателю до проверки на null.entry = clist_find(&file_list, file_list_by_name_detector, path, mod_rw); file = (struct file_t*)entry->data; if (entry != null) { return &file->io_buf; }
Почему вам не нравятся видеоуроки?
Кмк, формат статьи более удобен, я могу свободно перемещаться по статье глазами и скроллить, если я вдруг не въехал, я могу просто перечитать предложение еще раз, без всяких лишних действий, вдобавок в случае кода его можно без проблем копипастить.
Заголовок спойлераВ добавок я не очень люблю людей и не собираюсь слушать чей-то там бубнеж. Извините.assembled
25.09.2019 10:53#define FILE struct io_buf_t
Никогда не понимал, зачем использовать #define вместо typedef?
Komei
Почему вам не нравятся видеоуроки?
Напишу свой вариант ответа: потому что читать удобнее. Вот удобнее и всё тут.