Впровадження інтерфейсів для асемблера LC-3 на C

pic

Відносини між нашими інтерфейсами, структурами даних та їх реалізаціями в C

У моєму попередньому дописі ми досліджували теоретичні основи структур даних, необхідних для нашого асемблера LC-3. Сьогодні ми зануримося в те, як ці абстрактні концепти перетворюються на реальний код на C. Хоча багато сучасних мов пропонують високорівневі абстракції та вбудовані структури даних, реалізація їх у C вимагає від нас активно працювати з ручним керуванням пам’яттю та обережною маніпуляцією вказівниками.

Рівень інтерфейсу: Заголовкові файли

Розпочнемо з того, як ми визначаємо наші структури даних. В C ми зазвичай оголошуємо наші структури та їх інтерфейси у заголовкових (.h) файлах. Це розмежування інтерфейсу та реалізації є важливим для підтримки коду. Ось як ми визначили наші основні структури:

 // common/data_structures.h  
 typedef struct {  
 char* name; // ім'я мітки (динамічно виділене)  
 uint16_t address; // адреса пам'яті (від 0x0000 до 0xFFFF)  
 int line_number; // номер рядка джерела  
 bool is_defined; // визначено чи посилання  
 } symbol_entry_t;  

 typedef struct {  
 uint16_t opcode; // 4-бітний код операції  
 uint16_t operands[3]; // до 3 операндів  
 char* label; // асоційована мітка  
 uint16_t address; // адреса пам'яті  
 int line_number; // номер рядка джерела  
 bool has_imm5; // прапор негайного режиму  
 } instruction_record_t;  

 typedef struct {  
 char* label; // необов'язкова мітка  
 char* operation; // операція або псевдокоманда  
 char* operands; // необроблений рядок операндів  
 char* comment; // необов'язковий коментар  
 int line_number; // номер рядка  
 } source_line_t; 

Чому ми використовуємо typedef struct? Це ідіома C, яка дозволяє нам посилатися на типи наших структур без необхідності щоразу писати struct. Суфікс _t є поширеною конвенцією, що вказує на те, що це визначення типу.

Керування пам'яттю: Як це робимо в C

Однією з найбільших відмінностей при реалізації структур даних в C порівняно з вищими мовами є ручне керування пам'яттю. Давайте подивимося, як ми вирішуємо це для нашої таблиці символів:

 // symbol_table.c  
 symbol_table_t* create_symbol_table(void) {  
 symbol_table_t* st = malloc(sizeof(symbol_table_t));  
 if (st == NULL) return NULL; // malloc може не вдатися!  

 st->size = MAX_SYMBOLS;  
 st->buckets = calloc(st->size, sizeof(symbol_node_t*));  
 if (st->buckets == NULL) {  
 free(st); // Очищаємо, якщо друга алокація не вдалася  
 return NULL;  
 }  
 return st;  
 }  
 void free_symbol_table(symbol_table_t* st) {  
 if (st == NULL) return;  
 for (size_t i = 0; i < st->size; i++) {  
 symbol_node_t* node = st->buckets[i];  
 while (node != NULL) {  
 symbol_node_t* temp = node;  
 node = node->next;  
 free(temp->entry.name); // Звільняємо рядок імені мітки  
 free(temp); // Потім звільняємо саму ноду  
 }  
 }  
 free(st->buckets);  
 free(st);  
 }  

Декілька ключових моментів щодо керування пам'яттю в C:

  1. Завжди перевіряйте результат malloc: На відміну від мов з виключеннями, C не видає помилок, а просто повертає NULL, якщо алокація не вдалася.
  2. Звільняйте пам'ять у зворотному порядку: Коли звільняємо пам'ять, робимо це в зворотному порядку виділення, щоб уникнути висячих вказівників.
  3. Перевірки на NULL усюди: Завжди перевіряйте на NULL перед розіменуванням вказівників.

Обробка рядків: Спеціальна проблема

В C рядки — це просто масиви символів, що завершуються нульовим байтом (\0).
Це означає, що ми повинні бути особливо обережними при обробці рядків:

 // file_handler.c  
 bool read_next_line(file_handler_t* fh, source_line_t* source_line) {  
 char buffer[MAX_LINE_LENGTH];  
 if (fgets(buffer, MAX_LINE_LENGTH, fh->file) == NULL)   
 return false;  

 // Обрізаємо зайві пробіли в кінці  
 char* end = buffer + strlen(buffer) - 1;  
 while (end > buffer && isspace((unsigned char)*end)) {  
 *end = '\0';  
 end--;  
 }  

 // Створюємо власну копію рядка  
 source_line->operation = strdup(buffer);  
 if (source_line->operation == NULL)   
 return false; // strdup може не вдатися  

 return true;  
}

Чому кастити до unsigned char при використанні isspace? Це потрібно для правильного оброблення розширених ASCII-символів на деяких платформах. Це маленькі платформо-специфічні деталі, які роблять програмування на C як складним, так і цікавим.

Обробка помилок: Без виключень

C не має механізму обробки виключень, тому нам потрібно ретельно продумати, як обробляти помилки. Ось наш підхід:

 // error_handler.c  
 bool add_error(error_handler_t* eh, const char* filename,   
 int line_number, error_type type, const char* message) {  
 if (eh->count >= eh->capacity) {  
 size_t new_capacity = eh->capacity * 2;  
 error_message_t* new_errors = realloc(eh->errors,   
 new_capacity * sizeof(error_message_t));  
 if (new_errors == NULL) return false;  

 eh->errors = new_errors;  
 eh->capacity = new_capacity;  
 }  

 eh->errors[eh->count].filename = strdup(filename);  
 eh->errors[eh->count].line_number = line_number;  
 eh->errors[eh->count].type = type;  
 eh->errors[eh->count].message = strdup(message);  

 eh->count++;  
 return true;  
}

Зверніть увагу, як ми:

  1. Використовуємо булеві значення для позначення успіху/неуспіху
  2. Динамічно збільшуємо масив помилок
  3. Створюємо копії рядків, щоб уникнути проблем із життєвим циклом

Реалізація структур даних: Таблиця символів

Давайте подивимося, як ми реалізували нашу хеш-таблицю для таблиці символів:

 // symbol_table.c  
 bool add_symbol(symbol_table_t* st, const char* name,   
 uint16_t address, int line_number) {  
 if (st == NULL || name == NULL) return false;  
 uint32_t index = hash(name, st->size);  

 // Перевірка на дублікати  
 symbol_node_t* node = st->buckets[index];  
 while (node != NULL) {  
 if (strcmp(node->entry.name, name) == 0)   
 return false; // Вже існує  
 node = node->next;  
 }  
 // Створюємо нову ноду  
 symbol_node_t* new_node = malloc(sizeof(symbol_node_t));  
 if (new_node == NULL) return false;  
 // Створюємо власну копію імені  
 new_node->entry.name = strdup(name);  
 if (new_node->entry.name == NULL) {  
 free(new_node);  
 return false;  
 }  
 // Ініціалізуємо інші поля  
 new_node->entry.address = address;  
 new_node->entry.line_number = line_number;  
 new_node->entry.is_defined = true;  
 // Додаємо на початок ланцюга  
 new_node->next = st->buckets[index];  
 st->buckets[index] = new_node;  
 return true;  
}

Декілька важливих моментів щодо програмування на C:

  1. Захисне програмування: Ми перевіряємо всі вказівники перед їх використанням.
  2. Володіння рядками: Ми створюємо власну копію рядка за допомогою strdup.
  3. Очищення при помилці: Якщо будь-яка алокація не вдалася, ми очищаємо все, що вже було виділено.

Тестування в C: Фреймворк Unity

Тестування коду на C вимагає іншого підходу порівняно з вищими мовами. Ми використовуємо фреймворк для тестування Unity:

 // test_symbol_table.c  
 void test_add_symbol_valid(void) {  
 symbol_table_t* st = create_symbol_table();  
 TEST_ASSERT_NOT_NULL(st);  
 bool ok = add_symbol(st, "LOOP", 0x3000, 1);  
 TEST_ASSERT_TRUE(ok);  
 symbol_entry_t* entry;  
 bool found = lookup_symbol(st, "LOOP", &entry);  
 TEST_ASSERT_TRUE(found);  
 TEST_ASSERT_EQUAL_STRING("LOOP", entry->name);  
 TEST_ASSERT_EQUAL_UINT16(0x3000, entry->address);  
 free_symbol_table(st);  
}

Зверніть увагу, як ми:

  1. Завжди тестуємо помилки при алокації пам'яті
  2. Перевіряємо на витоки пам'яті
    3.
    Тестування крайніх випадків явно

Збирання всього разом

Всі ці структури даних працюють разом у нашому головному циклі асемблера:

 // main.c  
 int main(int argc, char* argv[]) {  
 file_handler_t* fh = create_file_handler(argv[1]);  
 symbol_table_t* st = create_symbol_table();  
 error_handler_t* eh = create_error_handler();  

 if (!fh || !st || !eh) {  
 // Обробка помилок ініціалізації  
 goto cleanup;  
 }  
 // Перший прохід: збір символів  
 source_line_t line;  
 while (read_next_line(fh, &line)) {  
 if (line.label) {  
 if (!add_symbol(st, line.label, current_address,   
 line.line_number)) {  
 add_error(eh, argv[1], line.line_number,   
 SYMBOL_ERROR, "Дублюючий символ");  
 goto cleanup;  
 }  
 }  
 // Обробка решти рядка...  
 }  
 // Більше обробки...  
cleanup:  
 free_file_handler(fh);  
 free_symbol_table(st);  
 free_error_handler(eh);  
 return has_errors(eh) ? EXIT_FAILURE : EXIT_SUCCESS;  
}

Зверніть увагу на використання goto cleanup для обробки помилок. Хоча goto часто вважається шкідливим, це поширений і прийнятий патерн в C для очищення в разі помилки.

Висновки: C гарний... але ціною

Хоча реалізація структур даних на C вимагає більшої уваги до деталей порівняно з вищими мовами, вона має кілька переваг:

  1. Повний контроль: Ми точно знаємо, як використовується наша пам'ять.
  2. Продуктивність: Немає прихованих алокацій або абстракцій.
  3. Портативність: Код на C може працювати майже на будь-якій платформі (простота структури компілятора, мінімальні вимоги до пам'яті, підтримка широкого спектра платформ), але є застереження, що "код, написаний один раз, може не працювати скрізь".

Давайте розглянемо останній пункт про портативність. Незважаючи на чудову портативність C, розробники часто стикаються з платформо-специфічними проблемами:

Розміри цілих чисел: int може бути 32 біти на одній платформі і 64 на іншій. Завжди використовуйте типи фіксованої ширини (наприклад, uint32_t), коли розмір важливий.

 // Не робіть цього  
 int potentially_different_size; // Розмір змінюється залежно від платформи  

 // Робіть так  
 uint32_t guaranteed_32_bits; // Однаковий розмір всюди

Порядок байтів: Різні платформи по-різному обробляють порядок байтів:

 // Це може працювати по-різному на великих та малих ендіан системах  
 uint16_t value = 0x1234;  
 uint8_t* bytes = (uint8_t*)&value;  

 // Краще підхід: явна обробка байтів  
 uint8_t bytes[2] = {  
 (value >> 8) & 0xFF, // Найбільш значущий байт  
 value & 0xFF // Найменш значущий байт  
};

Варіації компіляторів: Різні компілятори можуть:

  • По-різному пакувати структури
  • Мати різні поведінки препроцесора
  • По-різному реалізовувати невизначену поведінку
  • Підтримувати різні версії/функціональність стандарту C

Вирівнювання пам'яті: Деякі платформи мають суворі вимоги до вирівнювання:

 // Може працювати на деяких (наприклад, x86), але не працюватиме на інших (наприклад, ARM)  
 char buffer[sizeof(int)];  
 int* potentially_misaligned = (int*)buffer;  

 // Краще: використовуйте вирівняні типи або явне вирівнювання  
 alignas(alignof(int)) char aligned_buffer[sizeof(int)];

Ключ до успішного програмування на C (як я дізнався) полягає в тому, щоб:

  • Ретельно проектувати свої інтерфейси
  • Бути послідовним у керуванні пам'яттю
  • Використовувати чіткі патерни для обробки помилок
  • Тестувати ретельно, включаючи перевірку управління пам'яттю
  • Бути обізнаним про платформо-специфічні припущення
  • Документувати будь-які залежності від платформи
  • Використовувати незалежні від платформи типи і конструкції, коли це можливо

У нашому наступному пості ми розглянемо, як проводити юніт-тестування в C.

Уроки, які я засвоїв: працюючи в C, нічого не припускайте, перевіряйте все, завжди очищайте після себе і ніколи не вважайте сумісність з платформою гарантованою!

Перекладено з: Implementing Interfaces for an LC-3 Assembler in C

Leave a Reply

Your email address will not be published. Required fields are marked *