Відносини між нашими інтерфейсами, структурами даних та їх реалізаціями в 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:
- Завжди перевіряйте результат malloc: На відміну від мов з виключеннями, C не видає помилок, а просто повертає
NULL
, якщо алокація не вдалася. - Звільняйте пам'ять у зворотному порядку: Коли звільняємо пам'ять, робимо це в зворотному порядку виділення, щоб уникнути висячих вказівників.
- Перевірки на 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;
}
Зверніть увагу, як ми:
- Використовуємо булеві значення для позначення успіху/неуспіху
- Динамічно збільшуємо масив помилок
- Створюємо копії рядків, щоб уникнути проблем із життєвим циклом
Реалізація структур даних: Таблиця символів
Давайте подивимося, як ми реалізували нашу хеш-таблицю для таблиці символів:
// 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:
- Захисне програмування: Ми перевіряємо всі вказівники перед їх використанням.
- Володіння рядками: Ми створюємо власну копію рядка за допомогою
strdup
. - Очищення при помилці: Якщо будь-яка алокація не вдалася, ми очищаємо все, що вже було виділено.
Тестування в 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);
}
Зверніть увагу, як ми:
- Завжди тестуємо помилки при алокації пам'яті
- Перевіряємо на витоки пам'яті
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 вимагає більшої уваги до деталей порівняно з вищими мовами, вона має кілька переваг:
- Повний контроль: Ми точно знаємо, як використовується наша пам'ять.
- Продуктивність: Немає прихованих алокацій або абстракцій.
- Портативність: Код на 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