Стандарт ISO 7816 составлялся с большим "запасом" - описываемые протоколы, механизмы защиты и команды обладают избыточным количеством параметров, что в купе с тяжелым стилем изложения часто мешает усвоению материала. Для интереса можете почитать параграф про «атрибуты безопасности в расширенном формате (security attributes in expanded format)». Иногда складывается впечатление, что к технической части документа приложил руку юрист, а не инженер.
С учетом сказанного в представленной имплементации не будут реализованы некоторые механизмы. Изложение пойдет по маршруту "от ядра к оболочке", так что начнем с симулятора NVM.
Симулятор flash
За основу взята архитектура и логика микроконтроллера stm32f103c8t6, таким образом параметры будут следующими:
размер страницы:
1024 байт;количество страниц:
64 штуки;чтение: прямое обращение побайтово (
8 бит), полуслово (16 бит), слово (32 бита);запись: полуслово, при этом адрес выравнен на
16 бити только в очищенную ячейку. Допустимая возможность сброса всех битов в частично использованном полуслове не реализована;стирание: постранично.
Здесь и далее исходники будут в урезанном виде для лучшего восприятия. Ссылки на полную версию в конце статьи.
Чтение осуществляется функцией:
mm_Result
mm_read(uint32_t offset, uint8_t* byte)
{
// address to index conversion
offset = (offset - fs_start_addr);
*byte = flash_emu[offset];
return mm_Ok;
}
Она сохраняет во второй параметр значение одного байта с заданного адреса.
Функция записи же имеет несколько усложненную систему проверок
mm_Result
mm_write(uint32_t offset, uint16_t half_word)
{
if (offset & 0x00000001) {
printf("\n\t\t\t****HardFault****\n"
"Attempt to write at address '%08X' which isn't half-word aligned\n\n", offset
);
raise(SIGINT);
}
// address to index conversion
offset = (offset - fs_start_addr) / 2;
if (flash_emu[offset] != 0xFFFF) {
printf("\n\t\t\t****HardFault****\n"
"Attempt to write at address '%08X' (flash_emu[%d])\nwhich isn't blank and contains '%04X' value\n\n", temp, offset, ptr[offset]
);
raise(SIGINT);
}
flash_emu[offset] = half_word;
return mm_Ok;
}
В пользовательскую консоль будут выведены соответствующие сообщения если отступ не выровнен на 16 бит или же значение по заданному отступу не равно 0xFFFF. Затем генерируется сигнал прерывания, который обрабатывается в функции main().
В обеих функциях можно наблюдать следующее выражение:
offset = (offset - fs_start_addr) / 2;
Это преобразование адресов в индекс внутри массива flash_emu[]. Таким образом значение параметра offset преобразуется из, допустим, '0x55561800' в реальный индекс внутри массива.
Перезапись:
mm_Result
mm_rewrite_page(uint32_t start_page, uint32_t data_start_addr, uint8_t* data, uint32_t len)
{
mm_Result result = mm_Ok;
uint8_t* data_ptr = data;
uint32_t portion = 0;
do {
portion = PAGE_CEIL(start_page + 1) - data_start_addr;
if (len < portion) { // remaining bytes are less than page size
portion = len;
}
rewrite_next_page(start_page, data_start_addr, data_ptr, portion);
start_page = PAGE_CEIL(start_page + 1);
data_start_addr += portion;
data_ptr += portion;
} while (len -= portion);
return result;
}
Основной цикл функции учитывает ситуации, когда данные перекрывают две и более страницы:

Вышеописанная функция оперирует одной вспомогательной, упоминаемой в схеме:
static mm_Result
rewrite_next_page(const uint32_t page_addr, const uint32_t data_addr, uint8_t* data, uint32_t len)
{
mm_Result result = mm_Ok;
memset((uint8_t*)page_ram, 0xFF, PAGE_SIZE);
do {
// 1. copy all data from flash to ram
// 2. update fields in RAM
// 3. clear page
// 4. update entire page
} while (0);
return result;
}
Если совсем припрет, то исходники можно найти на гите (оставлю в конце статьи), здесь же просто пишу действия и логику работы.
Перво-наперво вводится очередной элемент: uint16_t page_ram[], который на реальном железе будет располагаться в оперативной памяти. Далее по шагам:
копируем содержимое всей страницы в
page_ram[];вносим необходимые изменения в
page_ram[];очищаем всю страницу (выставляем
0xFFFF) воflash_emu[];записываем содержимое
page_ram[]в страницуflash_emu[].
Стоит отметить, что функция mm_rewrite_page() будет вызвана только если целевое слово занято. Иначе mm_write() просто дополнит страницу нужными данными (без перезаписи всей страницы).
Вторая по значимости функция:
uint32_t
mm_allocate(uint16_t size)
{
size = WORD_CEIL(size);
block_t* current = (block_t*)fs_start_addr;
block_t* previous = (block_t*)fs_start_addr;
uint32_t address = 0;
do {
if (current->len == 0xFFFFFFFF) { // the block is empty
uint32_t off = (uint32_t)¤t->len;
// allocate this block
mm_write(off, size);
mm_write(off + 2, 0);
off = (uint32_t)¤t->prev;
mm_write(off, (uint16_t)(((uint32_t)previous + sizeof(block_t)) >> 16));
mm_write(off + 2, (uint16_t)(((uint32_t)previous + sizeof(block_t)) & 0x0000FFFF));
address = (uint32_t)current + sizeof(block_t);
break;
} else { // look for an another block
previous = current;
// shift to the next block
current = (block_t*)((uint8_t*)current + current->len + sizeof(block_t));
current = (block_t*)HEX_CEIL((uint32_t)current);
// check if the current address doesn't exceed flash boundary
if ((uint32_t)current + size >= fs_upper_addr) {
break;
}
}
} while (1);
return address;
}
Работает она просто: начиная с первого блока осуществляется поиск свободного (поле current->len равно 0xFFFFFFFF). Если таковой обнаруживается, то по текущему адресу заполняются поля блока block_t.
typedef struct {
uint32_t len; // длина блока
uint32_t prev; // указатель на предыдущий блок
} block_t;
Функция, которая вызывается самой первой эта:
uint32_t
mm_get_start_address(void)
{
fs_start_addr = PAGE_CEIL((uint32_t)flash_emu);
fs_upper_addr = (uint32_t)flash_emu + FLASH_SIZE_TOTAL;
// initially available memory
available_memory = FLASH_SIZE_TOTAL - (fs_start_addr - (uint32_t)flash_emu);
return fs_start_addr + sizeof(block_t);
}
здесь мы инициализируем следующие переменные:
fs_start_addr- первая доступная страницаfs_upper_addr- верхняя граница памятиavailable_memory- доступная память
Файловая система
Прежде чем имплементировать сущности, определенные в ISO, необходимо определить базовую архитектуру. Здесь за основу была взята Minix - учебная версия ФС, на базе которой созданы многие другие Unix-подобные системы. У нее очень простая архитектура:

typedef struct {
uint32_t magic; // маркер, может иметь любое значение
uint32_t inodes_count; // количество файлов в ФС
uint32_t inodes_capacity; // максимально допустимое кол-во файлов в ФС
uint32_t inodes_start; // начальный адрес Inode Table в ФС
} SuperBlock;
Super Block - главная единица метаданных в единственном экземпляре. Он записывается в начало первой доступной страницы, а его поля инициализируются функцией iso_initialize(), до которой мы обязательно доберемся, а пока вкратце:
inodes_count - счетчик элементов ФС (DF и EF). Увеличивается после успешного возвращения из функции iso_create().
inodes_capacity - максимальное количество элементов ФС. Inode расшифровывается как "index node" и представляет собой набор метаданных какого-нибудь файла (его размер, правила доступа и прочее). Всякий раз когда мы хотим обратиться к файлу система навигации будет искать в таблице Inode Table ноду оного, чтобы заполучить всю необходимую информацию, в том числе самую важную - адрес в NVM, по которому расположены пользовательские данные, хранящиеся в этом файле.
Иным словами, Inode - это паспорт файла.

В текущей реализации размер Inode Table задается на этапе компиляции посредством константы ITABLE_MEMORY_OCCUPATION_IN_PERCENTS, значение которой должно быть в диапазоне от 0 до 100. Если указать значение "10", то при условии, что у нас 64 Кбайт памяти, под эту таблицу будет выделено 6,4 Кбайт. На картинке снизу можно посмотреть параметры:

inodes_start - начальный адрес Inode Table. Следует сразу за SuperBlock.
Коль скоро пошла речь о записи, то настал подходящий момент сказать, что нам доступны не все 64 Кбайт. Этот маневр направлен на симуляцию реального железа, где пользовательские данные желательно записывать не сразу после секций .text, .data, .bss и прочих, а так сказать, с "новой страницы".

Inode Table - это массив фиксированного размера в котором хранятся структуры INode для каждого созданного файла. Учет файлов ведется посредством идентификатора FileID. Обе структуры будут описаны в следующей главе.
Пользовательские данные - сакральные знания человечества.
Логические элементы ISO 7816-4
В документе определены следующие сущности, формирующие файловую систему:
Vilidity Area: хранит информацию о селектированных элементах. Здесь необходимо добавить замечание: select'ирование - это как кликнуть мышкой на иконку приложения, однако допустимость дальнейших действий (чтение, запись и проч.) зависит от разграничений, выставленных в атрибутах безопасности.
typedef struct {
FileID parent_dir; // родительская папка текущего файла
FileID curr_dir; // текущая папка
FileID curr_file; // текущий файл
...
} ValidityArea;
Родительская и текущая папки всегда должны быть выставлены, но идентификатор текущего файла может пустовать.
Dedicated File (DF): папка. Самая обычная папка, со своим списком вложенных файлов и требованиями безопасности. Единственное важное уточнение - корень файловой системы именуется Master File (MF) с зарезервированным идентификатором (FID, File IDentifier) - 3F00h.
Elementary File (EF): файл с голыми байтами. Что и как в них хранить остается на усмотрение технического задания.
Как DF, так и EF имеют тип:
typedef struct {
uint16_t iNode; // индекс в массиве Inode Table
uint16_t fid; // идентификатор файла (по ISO 7816-4)
} FileID;
Также у них общая структура INode:
typedef struct {
uint16_t size; // 0x80; File size (EF only)
uint8_t desc[6]; // 0x82; file descriptor (e.g. DF, EF, LF, etc.)
uint16_t fid; // 0x83; file ID (e.g. 3F00, 0101, etc.)
uint8_t aid[16]; // 0x84; application AID (DF only)
uint8_t sfi; // 0x88; short file ID (EF only)
uint8_t lcs; // 0x8A; Life cycle stage
SACompact sacf; // 0x8C; security attributes in compact format
uint16_t se; // 0x8D; the FID of associated securiy environment (DF only)
uint32_t data; // points to the associated data block
} INode;
Она воспроизведена с учетом требований ISO 7816 с небольшим дополнением в виде поля data, которое хранит адрес пользовательских данных в NVM.
С базовыми структурами закончили - переходим к функциям.
Инициализация файловой системы, должна вызываться при каждом запуске:
ISO_SW
iso_initialize(void)
{
do {
// 1. сохраняем в va.spr_blk_addr адрес Super Block'а,
// т.е. это адрес первой "чистой страницы"
va.spr_blk_addr = mm_get_start_address();
// 2. считываем из NVM состояние Super Block'а
hlp_read_data(va.spr_blk_addr, (uint8_t*)&va.spr_blk, sizeof(SuperBlock));
// 3.1 если Super Block уже проинициализирован, то выходим
if (va.spr_blk.magic == 0xCAFEBABE) {
break;
}
// 3.2 иначе: выделить память в NVM под Super Block
va.spr_blk_addr = mm_allocate(sizeof(SuperBlock));
// 4. выделить память под INode Table
va.spr_blk.inodes_start = mm_allocate(inode_table_size)
// 5. проинициализировать структуру SuperBlock
va.spr_blk.magic = 0xCAFEBABE;
va.spr_blk.inodes_count = 0x00;
va.spr_blk.inodes_capacity = inode_table_size / sizeof(INode); // 1024 / 64 = 96
result = hlp_write_data(va.spr_blk_addr, (uint8_t*)&va.spr_blk, sizeof(SuperBlock));
} while (0);
return result;
}
Здесь всё просто: проверяем, была ли файловая система проинициализирована или нет. Если да, значит на карте уже есть некая структура.
Далее чуть сложней - функция создания элемента файловой системы:
ISO_SW
iso_create_file(Apdu* apdu)
{
do {
// 1. начала проверяем наличие свободных мест в Inode Table
if (va.spr_blk.inodes_count >= va.spr_blk.inodes_capacity) {
break;
}
// 2. парсим входную строку
if ((result = hlp_parse_params(&inode, cdata, cdata_len)) != SW_OK) {
break;
}
// 3. проверяем корректность входных данных
if ((result = hlp_check_consistency(&inode)) != SW_OK) {
break;
}
// 3. выделяем в NVM память под пользовательские данные
if ((result = hlp_allocate_data_block(&va, &inode)) != SW_OK) {
break;
}
// 4. сохраняем INode вновь созданного файла в Inode Table
if ((result = hlp_store_inode(&va, &inode)) != SW_OK) {
break;
}
result = SW_OK;
} while (0);
return result;
}
SuperBlock отслеживает количество создаваемых файлов на предмет переполнения памяти, и эта проверка должна быть самой первой.
Входная строка представляет собой APDU команду, что-то вроде того:
00e0000019 621782013883023f008a01018d0240038c076fffffffffffff.
Здесь поле INS заголовка APDU равен 'E0', что соответствует команде CREATE FILE из стандарта. Далее следует поле данных в формате BER-TLV, которое кодирует параметры корневой директории MF.
В этой статье мы не будем углубляться в премудрости этого протокола, т.к. о нем сказано много, ровно как и о формате BER-TLV. Вам достаточно будет посмотреть примеры на питоне из папки tests проекта, а также комментарии к структуре INode, где найдете референсные данные о тегах.
Затем идут процессы по парсингу входной строки (hlp_parse_params()), ее валидация (hlp_check_consistency()) и выделение памяти под пользовательские данные (hlp_allocate_data_block()). В самом конце мы сохраняем INode файла в Inode Table.
Еще одна функция, на которой я бы хотел остановиться поподробней:
static ISO_SW
iso_select_by_name(const uint16_t fid)
{
// 1. находим в Inode Table текущую директорию.
uint16_t idx = va.curr_dir.iNode;
INode* inode_array = (INode*)va.spr_blk.inodes_start;
INode* currentDf = (INode*)&inode_array[idx];
do {
hlp_va_set_current_ef(&va, FID_RESERVED_1, FID_RESERVED_1);
// 2. особый случай: селектируется MF, который всегда доступен.
// Выбираем его и возвращаемся из функции.
if (fid == FID_MASTER_FILE) {
result = SW_OK;
hlp_va_set_current_df(&va, FID_MASTER_FILE, 0x00);
hlp_va_set_parent_df(&va, 0x00, 0x00);
break;
}
uint32_t count = 0;
// 3. Вычитываем количество дочерних файлов текущей директории
hlp_read_data(df_children_count(currentDf), count)
FileID next;
uint32_t i;
// 4. Начиная с первого файла директории, проводим сравнение его FID
// с искомым значением, переданным в качестве аргумента.
for (i = 0; i < count; ++i) {
// 4.1. Для этого мы последовательно вычитываем FileID файлов
hlp_read_data(df_children_list(currentDf)[i], next)
// 4.2. Затем сравниваем его целевым значением
if (fid == next.fid) {
// 4.3. Файл обнаружен. Посредством поля FileID.iNode берем
// указатель на этот файл в Inode Table.
currentDf = (INode*)&inode_array[next.iNode];
if (currentDf->desc[0] != ft_DF) {
// 4.3.1. Искомый файл не является директорией. В таком случае
// обновляем состояние у 'ValidityArea.current_file', затем выходим
hlp_va_set_current_ef(&va, next.fid, next.iNode);
break;
}
// 4.3.2. Выбранный файл имеет тип DF, тогда y ValidityArea
// надо обновить другие поля.
hlp_va_set_parent_df(&va, df_children_list(currentDf)[1].fid, df_children_list(currentDf)[1].iNode);
hlp_va_set_current_df(&va, next.fid, next.iNode);
break;
}
}
result = SW_OK;
} while (0);
return result;
}
Эта функция является основой навигации по файловой системе. Остальные две - iso_select_by_path() и iso_select() - являются обертками вокруг нее. Первая пытается найти и селектировать файл по абсолютному (от MF) или относительному пути (от текущей директории), а вторая является интерфейсом для пользователя: в зависимости от поля P1 (00 или 08) заголовка APDU, она будет вызывать одну из двух представленных.
Про функцию iso_read() многого не скажешь - обычная обертка вокруг более низких функций, рассмотренных в предыдущих главах статьи. Однако хотелось бы остановиться на следующей функции:
ISO_SW
hlp_write_data(uint32_t offset, uint8_t* data, uint32_t len)
{
ISO_SW result = SW_OK;
do {
uint8_t byte = 0;
// 1. check if an area we're about to write into is clear or not
for (uint32_t i = 0; i < len; ++i) {
mm_read(offset + i, &byte) != mm_Ok);
if (byte != 0xFF) {
break;
}
}
// 2.1. If even a byte isn't clear, then call mm_rewrite_page()
if (byte != 0xFF) {
mm_rewrite_page(PAGE_ALIGN(offset), offset, data, len);
// 2.2. else - just write data starting from the given offset.
} else {
uint16_t half_word = 0x00;
uint8_t data_tail = len & 0x01;
len &= 0xFE; // 2.2.1. truncate the last odd bytes
// 2.2.2. update records
for (uint32_t i = 0; i < len; i += 2) {
half_word |= (uint16_t)data[i ] & 0x00FF;
half_word |= (uint16_t)data[i + 1] << 8;
if (mm_write(offset + i, half_word) != mm_Ok) {
result = SW_MEMORY_FAILURE;
break;
}
half_word = 0;
}
// Do we have a last byte to be written? I.e. is the number of bytes to be written is odd?
// 2.2.3. write the last odd byte
if (data_tail) {
half_word = (0xFF00 | ((uint16_t)data[len] & 0x00FF));
if (mm_write(offset + len, half_word) != mm_Ok) {
result = SW_MEMORY_FAILURE;
}
}
}
} while (0);
return result;
}
Этот внутренний "помогатор" вызывается из интерфейсной функции iso_write_binary() и делает всю основную работу:
начиная с заданного смещения в памяти, считывает ее содержимое на всю длиную входных данных, чтобы убедиться, что область "чиста".
Если область уже имеет запись, тогда вызывается
mm_rewrite_page(), которая обновит и перезапишет всю страницу.Если же область чиста, тогда осуществляется запись по заданному смещению.
По третьему пункту требуются уточнения: нельзя просто так взять и записать нечетное количество данных, т.к. оперируем мы 16-битным словом. В этой связи делается следующий трюк:
len &= 0xFE;- выравниваем вниз длину записи: было 3 - стало 2. Был 1 - стало 0 (см. коммент 2.2.1 в коде выше);data_tail = len & 0x01;- выставляем флаг наличия нечетного байта;if (data_tail)- сюда мы попадем если длина входных данных была нечетной (см. коммент 2.2.3 в коде выше) и формируем переменную half_word соответствующим образом.
Заключение
Эта статья написана в процессе работы над данным проектом, потому в материале могут быть (и будут) обранужены неточности и расхождения. Еще больше претензий (лично у меня) к качеству кода и решениям, как принятым, так и отложенным на будущее. Тут непаханое поле для "улучшайзинга": на очереди стоят работы по добавлению функции удаления файлов, механизмы безопасности (PIN, External Authenticate, Secure Messaging), имплементация кучи и прочее-прочее.
Надеюсь, статья будет полезна тем, кто собирается рабоать в области смарт-карт технологий.
Slavik2025
*byte = flash_emu[offset]; - вот такую однотактовую команду должна поддерживать ваша аппаратная флеш фс/ddr по spi, за один клок. А также однотактовые memcpy.Следование чужим стандартам приводит к захвату ваших разработок, так как ваши разработки становятся совместимы с чужим оборудованием и стандартами.
Вам бы хорошо перейти с двоичной и шестнадцатиричной системы на положительную десятичную с таблицами умножения, суммирования, (таблицы деления и вычитания - почему не учат и не печатают на детских тетрадках, как вы думаете ?), с файлов на книги учёта (страницы, заголовки, параграфы, строчки, оглавления, преамбулы, рукописные шрифты и картинки - "для улучшения и облегчения восприятия" их искуственным разумом для встраивания в чужие LLM).
А ещё лучше китайский способ умножения и использование только натуральных чисел для ускорения расчётов, с вычисляемыми по требованию (например, натуральное 314 / на натуральное 99+9/10+9/100 и т.д.)