Читання та запис даних
При створенні оптимізованих програм Solana, ефективна серіалізація та десеріалізація даних може суттєво вплинути на продуктивність.
Хоча Pinocchio не вимагає низькорівневих операцій з пам'яттю, розуміння того, як ефективно читати та записувати дані облікових записів, допоможе вам створювати швидші програми.
Техніки, описані в цьому посібнику, працюють з будь-яким фреймворком розробки Solana; незалежно від того, чи використовуєте ви Pinocchio, Anchor або нативний SDK. Ключовим є продумане проєктування структур даних та безпечна обробка серіалізації.
Коли використовувати небезпечний код
Використовуйте небезпечний код лише коли:
Вам потрібна максимальна продуктивність, і ви виміряли, що безпечні альтернативи занадто повільні
Ви можете ретельно перевірити всі інваріанти безпеки
Ви чітко документуєте вимоги безпеки
Safety Principles
Працюючи з необробленими масивами байтів та операціями з пам'яттю, ми повинні бути обережними, щоб уникнути невизначеної поведінки. Розуміння цих принципів є вирішальним для написання правильного та надійного коду.
Перевірка меж буфера
Завжди перевіряйте, що ваш буфер достатньо великий перед будь-якою операцією читання чи запису. Читання або запис за межами виділеної пам'яті призводить до невизначеної поведінки.
// Good: Check bounds first
if data.len() < size_of::<u64>() {
return Err(ProgramError::InvalidInstructionData);
}
let value = u64::from_le_bytes(data[0..8].try_into().unwrap());
// Bad: No bounds checking - could panic or cause UB
let value = u64::from_le_bytes(data[0..8].try_into().unwrap());Вимоги до вирівнювання
Кожен тип у Rust має вимогу до вирівнювання, яка визначає, де він може бути розміщений у пам'яті. Читання типу з пам'яті, який не правильно вирівняний, призводить до невизначеної поведінки. Більшість примітивних типів вимагають вирівнювання, рівного їхньому розміру:
u8: 1-байтове вирівнюванняu16: 2-байтове вирівнюванняu32: 4-байтове вирівнюванняu64: 8-байтове вирівнювання
Це означає, що u64 повинен зберігатися за адресами пам'яті, які діляться на 8, тоді як u16 повинен починатися з парних адрес.
Якщо це не дотримується, компілятор автоматично вставить невидимі "вирівнювальні" байти між полями структури, щоб забезпечити відповідність кожного поля вимогам вирівнювання.
Ось як виглядає структура з поганим порядком полів:
#[repr(C)]
struct BadOrder {
small: u8, // 1 byte
// padding: [u8; 7] since `big` needs to be aligned at 8 bytes.
big: u64, // 8 bytes
medium: u16, // 2 bytes
// padding: [u8; 6] since the struct size needs to be aligned to 8 bytes.
}Компілятор вставляє 7 вирівнювальних байтів після small, оскільки big потребує 8-байтового вирівнювання. Потім він додає ще 6 байтів у кінці, щоб зробити загальний розмір (24 байти) кратним 8, витрачаючи даремно 13 байтів.
Кращим способом було б упорядкувати поля структури так:
#[repr(C)]
struct GoodOrder {
big: u64, // 8 bytes
medium: u16, // 2 bytes
small: u8, // 1 byte
// padding: [u8; 5] since the struct size needs to be aligned to 8 bytes.
}Розміщуючи більші поля першими, ми зменшуємо вирівнювання з 13 байтів до лише 5 байтів.
Існує інший, більш просунутий спосіб серіалізації та десеріалізації даних для максимальної ефективності використання простору. Ми можемо створити структури з нульовим вирівнюванням, де вимоги вирівнювання повністю усуваються:
#[repr(C)]
struct ByteArrayStruct {
big: [u8; 8], // represents u64
medium: [u8; 2], // represents u16
small: u8,
}Розмір у цьому випадку становить рівно 11 байтів, оскільки все вирівняно по 1 байту.
Допустимі бітові шаблони
Не всі бітові шаблони є допустимими для кожного типу. Типи, як-от bool, char та enums мають обмежені допустимі значення. Читання недопустимих бітових шаблонів у ці типи є невизначеною поведінкою.
Читання даних
Існує кілька підходів до читання даних з буферів облікових записів, кожен з різними компромісами:
Десеріалізація поле за полем (рекомендовано)
Найбезпечніший підхід — десеріалізувати кожне поле окремо. Це дозволяє уникнути всіх проблем з вирівнюванням, оскільки ви працюєте з масивами байтів:
pub struct DepositInstructionData {
pub amount: u64,
pub recipient: Pubkey,
}
impl<'a> TryFrom<&'a [u8]> for DepositInstructionData {
type Error = ProgramError;
fn try_from(data: &'a [u8]) -> Result<Self, Self::Error> {
if data.len() < (size_of::<u64>() + size_of::<Pubkey>()) {
return Err(ProgramError::InvalidInstructionData);
}
// No alignment issues: we're reading bytes and converting
let amount = u64::from_le_bytes(
data[0..8].try_into()
.map_err(|_| ProgramError::InvalidInstructionData)?
);
let recipient = Pubkey::try_from(&data[8..40])
.map_err(|_| ProgramError::InvalidInstructionData)?;
Ok(Self { amount, recipient })
}
}Десеріалізація без копіювання
Це можна використовувати для максимальної продуктивності з правильно вирівняними структурами, але це вимагає ретельної перевірки вирівнювання:
#[repr(C)]
pub struct Config {
pub authority: Pubkey,
pub mint_x: Pubkey,
pub mint_y: Pubkey,
pub seed: u64, // This field requires 8-byte alignment
pub fee: u16, // This field requires 2-byte alignment
pub state: u8,
pub config_bump: u8,
}
impl Config {
pub const LEN: usize = size_of::<Self>();
pub fn from_bytes(data: &[u8]) -> Result<&Self, ProgramError> {
if data.len() != Self::LEN {
return Err(ProgramError::InvalidAccountData);
}
// Critical: Check alignment for the most restrictive field (u64 in this case)
if (data.as_ptr() as usize) % core::mem::align_of::<Self>() != 0 {
return Err(ProgramError::InvalidAccountData);
}
// SAFETY: We've verified length and alignment
Ok(unsafe { &*(data.as_ptr() as *const Self) })
}
}
// Alternative: Avoid alignment issues entirely by using byte arrays for types with
// alignment requirement greater than 1 and provide accessor methods
#[repr(C)]
pub struct ConfigSafe {
pub authority: Pubkey,
pub mint_x: Pubkey,
pub mint_y: Pubkey,
seed: [u8; 8], // Convert with u64::from_le_bytes when needed
fee: [u8; 2], // Convert with u16::from_le_bytes when needed
pub state: u8,
pub config_bump: u8,
}
impl ConfigSafe {
pub fn from_bytes(data: &[u8]) -> Result<&Self, ProgramError> {
if data.len() != size_of::<Self>() {
return Err(ProgramError::InvalidAccountData);
}
// SAFETY: No alignment check needed - everything is u8 aligned
Ok(unsafe { &*(data.as_ptr() as *const Self) })
}
pub fn seed(&self) -> u64 {
u64::from_le_bytes(self.seed)
}
pub fn fee(&self) -> u16 {
u16::from_le_bytes(self.fee)
}
}Як бачите, поля seed і fee є приватними. Це тому, що ми завжди повинні використовувати методи доступу для читання даних, оскільки їхні значення представлені масивами байтів.
Коли ви звертаєтеся до поля напряму (config.seed), компілятору може знадобитися створити посилання на розташування цього поля в пам'яті, навіть тимчасово. Якщо це поле не вирівняне належним чином, створення посилання є невизначеною поведінкою, навіть якщо ви ніколи явно не використовуєте це посилання!
Методи доступу уникають цього, виконуючи операцію читання в межах області методу, де компілятор може оптимізувати будь-які проміжні посилання.
#[repr(C, packed)] // This can cause unaligned fields!
pub struct PackedConfig {
pub state: u8,
pub seed: u64, // This u64 might not be 8-byte aligned due to packing
}
impl PackedConfig {
pub fn seed(&self) -> u64 {
self.seed // Safe: Direct value copy, no reference created
}
}
// Usage:
let config = PackedConfig::load(account)?;
// ❌ UNDEFINED BEHAVIOR: Creates a reference to potentially unaligned field
let seed_ref = &config.seed; // Compiler must create a reference here!
// ❌ UNDEFINED BEHAVIOR: Even this can be problematic
let seed_value = config.seed; // May create temporary reference internally
// ✅ SAFE: Accessor method reads value without creating reference
let seed_value = config.seed(); // No intermediate referenceУ цьому випадку у нас немає "спеціальних типів", але завжди пам'ятайте, що деякі типи вимагають особливої уваги через недійсні бітові шаблони:
pub struct StateAccount {
pub is_active: bool,
pub state_type: StateType,
pub data: [u8; 32],
}
#[repr(u8)]
pub enum StateType {
Inactive = 0,
Active = 1,
Paused = 2,
}
impl StateAccount {
pub fn from_bytes(data: &[u8]) -> Result<Self, ProgramError> {
if data.len() < size_of::<Self>() {
return Err(ProgramError::InvalidAccountData);
}
// Safely handle bool (only 0 or 1 are valid)
let is_active = match data[0] {
0 => false,
1 => true,
_ => return Err(ProgramError::InvalidAccountData),
};
// Safely handle enum
let state_type = match data[1] {
0 => StateType::Inactive,
1 => StateType::Active,
2 => StateType::Paused,
_ => return Err(ProgramError::InvalidAccountData),
};
let mut data_array = [0u8; 32];
data_array.copy_from_slice(&data[2..34]);
Ok(Self {
is_active,
state_type,
data: data_array,
})
}
}Небезпечні шаблони, яких слід уникати
Ось поширені шаблони, які можуть призвести до невизначеної поведінки і яких слід уникати:
Використання
transmute()з невирівняними даними
// ❌ UNDEFINED BEHAVIOR: transmute requires proper alignment
let value: u64 = unsafe { core::mem::transmute(bytes_slice) };transmute() припускає, що вихідні дані правильно вирівняні для цільового типу. Якщо ви працюєте з довільними байтовими зрізами, це припущення часто порушується.
Приведення вказівників до упакованих структур
#[repr(C, packed)]
pub struct PackedConfig {
pub state: u8,
pub seed: u64, // This u64 is only 1-byte aligned!
pub authority: Pubkey,
}
// ❌ UNDEFINED BEHAVIOR: Creates references to unaligned fields
let config = unsafe { &*(data.as_ptr() as *const PackedConfig) };
let seed_value = config.seed; // UB: May create reference to unaligned u64Навіть якщо структура поміщається в пам'яті, доступ до багатобайтових полів може створювати невирівняні посилання.
Прямий доступ до полів упакованих структур
#[repr(C, packed)]
pub struct PackedStruct {
pub a: u8,
pub b: u64,
}
let packed = /* ... */;
// ❌ UNDEFINED BEHAVIOR: Creates reference to unaligned field
let b_ref = &packed.b;
// ❌ UNDEFINED BEHAVIOR: May create temporary reference
let b_value = packed.b;Припущення про вирівнювання без перевірки
// ❌ UNDEFINED BEHAVIOR: No alignment check
let config = unsafe { &*(data.as_ptr() as *const Config) };Те, що дані поміщаються, не означає, що вони правильно вирівняні.
Неправильне використання
read_unaligned()
// ❌ WRONG: read_unaligned needs proper layout, not just size
#[repr(Rust)] // Default layout - not guaranteed!
pub struct BadStruct {
pub field: u64,
}
let value = unsafe { (data.as_ptr() as *const BadStruct).read_unaligned() };read_unaligned() все одно вимагає, щоб структура мала передбачуване розташування (#[repr(C)]).
Writing Data
Безпечний запис даних слідує тим самим принципам, що й читання:
Серіалізація поле за полем (рекомендовано)
impl Config {
pub fn write_to_buffer(&self, data: &mut [u8]) -> Result<(), ProgramError> {
if data.len() != Self::LEN {
return Err(ProgramError::InvalidAccountData);
}
let mut offset = 0;
// Write authority
data[offset..offset + 32].copy_from_slice(self.authority.as_ref());
offset += 32;
// Write mint_x
data[offset..offset + 32].copy_from_slice(self.mint_x.as_ref());
offset += 32;
// Write mint_y
data[offset..offset + 32].copy_from_slice(self.mint_y.as_ref());
offset += 32;
// Write seed
data[offset..offset + 8].copy_from_slice(&self.seed.to_le_bytes());
offset += 8;
// Write fee
data[offset..offset + 2].copy_from_slice(&self.fee.to_le_bytes());
offset += 2;
// Write state
data[offset] = self.state;
offset += 1;
// Write config_bump
data[offset] = self.config_bump;
Ok(())
}
}Цей підхід є найбезпечнішим методом, оскільки явно серіалізує кожне поле в байтовий буфер:
Відсутність проблем з вирівнюванням: ви записуєте в байтовий масив
Явний порядок байтів: ви контролюєте порядок байтів за допомогою to_le_bytes()
Чітке розташування в пам'яті: легко налагоджувати та розуміти
Відсутність невизначеної поведінки: всі операції виконуються над байтовими масивами
Пряма мутація (без копіювання)
Для максимальної продуктивності ви можете привести байтовий буфер до структури та безпосередньо змінювати поля. Це вимагає правильного вирівнювання структури:
impl Config {
pub fn from_bytes_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> {
if data.len() != Self::LEN {
return Err(ProgramError::InvalidAccountData);
}
// Check alignment
if (data.as_ptr() as usize) % core::mem::align_of::<Self>() != 0 {
return Err(ProgramError::InvalidAccountData);
}
// SAFETY: We've verified length and alignment
Ok(unsafe { &mut *(data.as_mut_ptr() as *mut Self) })
}
}Коли вирівнювання перевірено і структура використовує #[repr(C)], пряма мутація полів не створює невирівняних посилань.
Підхід з байтовим масивом та сеттерами (найбезпечніший + швидкий)
Найкраще з обох світів: ми можемо використовувати байтові масиви внутрішньо, але надавати ергономічні сеттери:
#[repr(C)]
pub struct ConfigSafe {
pub authority: Pubkey,
pub mint_x: Pubkey,
pub mint_y: Pubkey,
seed: [u8; 8],
fee: [u8; 2],
pub state: u8,
pub config_bump: u8,
}
impl ConfigSafe {
pub fn from_bytes_mut(data: &mut [u8]) -> Result<&mut Self, ProgramError> {
if data.len() != size_of::<Self>() {
return Err(ProgramError::InvalidAccountData);
}
// No alignment check needed - everything is u8 aligned
Ok(unsafe { &mut *(data.as_mut_ptr() as *mut Self) })
}
pub fn seed(&self) -> u64 {
u64::from_le_bytes(self.seed)
}
pub fn fee(&self) -> u16 {
u16::from_le_bytes(self.fee)
}
// Setters that handle endianness correctly
pub fn set_seed(&mut self, seed: u64) {
self.seed = seed.to_le_bytes();
}
pub fn set_fee(&mut self, fee: u16) {
self.fee = fee.to_le_bytes();
}
}Це ідеально, тому що:
Відсутність проблем з вирівнюванням: усі поля вирівняні по байтах
Швидка пряма мутація: відсутні накладні витрати на серіалізацію після початкового налаштування
Послідовний порядок байтів: сеттери обробляють конвертацію порядку байтів
Типова безпека: сеттери приймають очікувані типи, а не байтові масиви
Dynamically Sized Data
Коли це можливо, уникайте зберігання даних динамічного розміру безпосередньо в облікових записах. Однак деякі випадки використання вимагають цього.
Якщо ваш обліковий запис містить динамічні дані, завжди розміщуйте всі поля статичного розміру на початку вашої структури, а динамічні дані додавайте в кінці.
Одне динамічне поле
Це найпростіший випадок: одна секція змінної довжини в кінці вашого облікового запису:
#[repr(C)]
pub struct DynamicAccount {
pub fixed_data: [u8; 32],
pub counter: u64,
// Dynamic data follows after the struct in memory
// Layout: [fixed_data][counter][dynamic_data...]
}
impl DynamicAccount {
pub const FIXED_SIZE: usize = size_of::<Self>();
/// Safely parse account with dynamic data
pub fn from_bytes_with_dynamic(data: &[u8]) -> Result<(&Self, &[u8]), ProgramError> {
if data.len() < Self::FIXED_SIZE {
return Err(ProgramError::InvalidAccountData);
}
// SAFETY: We've verified the buffer is large enough for the fixed part
// The fixed part only contains [u8; 32] and u64, which have predictable layout
let fixed_part = unsafe { &*(data.as_ptr() as *const Self) };
// Everything after the fixed part is dynamic data
let dynamic_part = &data[Self::FIXED_SIZE..];
Ok((fixed_part, dynamic_part))
}
/// Get mutable references to both parts
pub fn from_bytes_mut_with_dynamic(data: &mut [u8]) -> Result<(&mut Self, &mut [u8]), ProgramError> {
if data.len() < Self::FIXED_SIZE {
return Err(ProgramError::InvalidAccountData);
}
// Split the buffer to avoid borrowing issues
let (fixed_bytes, dynamic_bytes) = data.split_at_mut(Self::FIXED_SIZE);
// SAFETY: We've verified the size and split safely
let fixed_part = unsafe { &mut *(fixed_bytes.as_mut_ptr() as *mut Self) };
Ok((fixed_part, dynamic_bytes))
}
}
/// Writing single dynamic field
impl DynamicAccount {
pub fn write_with_dynamic(
data: &mut [u8],
fixed_data: &[u8; 32],
counter: u64,
dynamic_data: &[u8]
) -> Result<(), ProgramError> {
let total_size = Self::FIXED_SIZE + dynamic_data.len();
if data.len() != total_size {
return Err(ProgramError::InvalidAccountData);
}
// Write fixed part field by field (safest approach)
data[0..32].copy_from_slice(fixed_data);
data[32..40].copy_from_slice(&counter.to_le_bytes());
// Write dynamic part
data[Self::FIXED_SIZE..].copy_from_slice(dynamic_data);
Ok(())
}
/// Update just the dynamic portion
pub fn update_dynamic_data(&mut self, account_data: &mut [u8], new_data: &[u8]) -> Result<(), ProgramError> {
if account_data.len() < Self::FIXED_SIZE + new_data.len() {
return Err(ProgramError::InvalidAccountData);
}
// Write new dynamic data
account_data[Self::FIXED_SIZE..Self::FIXED_SIZE + new_data.len()].copy_from_slice(new_data);
Ok(())
}
}Щоб уникнути невизначеної поведінки, завжди перевіряйте, що буфер даних облікового запису має розмір не менший за статично визначену частину. Динамічна секція може бути порожньою, тому ця перевірка є необхідною.
Така структура гарантує, що зміщення для полів фіксованого розміру завжди відомі, незалежно від довжини динамічних даних.
Існує два основних сценарії при читанні динамічно розмірних даних:
Одне динамічне поле в кінці: ви можете легко визначити розмір та зміщення динамічних даних під час виконання таким чином:
const DYNAMIC_DATA_START_OFFSET: usize = size_of::<[u8; 32]>();
#[repr(C)]
pub struct DynamicallySizedAccount {
pub sized_data: [u8; 32],
// pub dynamic_data: &'info [u8], // Not part of the struct, but follows in the buffer
}
impl DynamicallySizedAccount {
/// Returns the length of the dynamic data section.
#[inline(always)]
pub fn get_dynamic_data_len(data: &[u8]) -> Result<usize, ProgramError> {
if data.len().le(&DYNAMIC_DATA_START_OFFSET) {
return Err(ProgramError::InvalidAccountData);
}
Ok(data.len() - DYNAMIC_DATA_START_OFFSET)
}
/// Returns a slice of the dynamic data.
#[inline(always)]
pub fn read_dynamic_data(data: &[u8]) -> Result<&[u8], ProgramError> {
if data.len().le(&DYNAMIC_DATA_START_OFFSET) {
return Err(ProgramError::InvalidAccountData);
}
Ok(&data[DYNAMIC_DATA_START_OFFSET..])
}
}Кілька динамічних полів
Цей підхід складніший, оскільки нам потрібен спосіб визначення довжини кожного динамічного поля, крім останнього. Найпоширеніший підхід — додати префікс з довжиною до кожного динамічного поля (крім останнього), щоб ми могли правильно розібрати буфер.
Ось простий і надійний шаблон: зберігайте довжину першого динамічного поля як u8 (або u16 тощо, якщо вам потрібні більші розміри) одразу після даних статичного розміру. Далі йде перше динамічне поле, а друге динамічне поле займає решту буфера.
#[repr(C)]
pub struct MultiDynamicAccount {
pub fixed_data: [u8; 32],
pub timestamp: u64,
// Layout: [fixed_data][timestamp][len1: u8][data1][data2: remainder]
}
impl MultiDynamicAccount {
pub const FIXED_SIZE: usize = size_of::<Self>();
pub const LEN_PREFIX_SIZE: usize = size_of::<u8>();
pub const MIN_SIZE: usize = Self::FIXED_SIZE + Self::LEN_PREFIX_SIZE;
/// Parse account with two dynamic sections
pub fn parse_dynamic_fields(data: &[u8]) -> Result<(&[u8; 32], u64, &[u8], &[u8]), ProgramError> {
if data.len() < Self::MIN_SIZE {
return Err(ProgramError::InvalidAccountData);
}
// Extract fixed data safely
let fixed_data = data[..32].try_into()
.map_err(|_| ProgramError::InvalidAccountData)?;
let timestamp = u64::from_le_bytes(
data[32..40].try_into()
.map_err(|_| ProgramError::InvalidAccountData)?
);
// Read length of first dynamic field (single byte)
let len = data[Self::FIXED_SIZE] as usize;
// Validate we have enough data
if data.len() < Self::MIN_SIZE + len {
return Err(ProgramError::InvalidAccountData);
}
let data_1 = &data[Self::MIN_SIZE..Self::MIN_SIZE + len];
let data_2 = &data[Self::MIN_SIZE + len..]; // Remainder
Ok((fixed_data, timestamp, data_1, data_2))
}
/// Write account with two dynamic sections
pub fn write_with_multiple_dynamic(
buffer: &mut [u8],
fixed_data: &[u8; 32],
timestamp: u64,
data_1: &[u8],
data_2: &[u8]
) -> Result<(), ProgramError> {
let total_size = Self::MIN_SIZE + data_1.len() + data_2.len();
if buffer.len() != total_size {
return Err(ProgramError::InvalidAccountData);
}
// Validate data_1 length fits in u8
if data_1.len() > u8::MAX as usize {
return Err(ProgramError::InvalidInstructionData);
}
let mut offset = 0;
// Write fixed data
buffer[offset..offset + 32].copy_from_slice(fixed_data);
offset += 32;
buffer[offset..offset + 8].copy_from_slice(×tamp.to_le_bytes());
offset += 8;
// Write length prefix for data1 (single byte)
buffer[offset] = data_1.len() as u8;
offset += 1;
// Write data1
buffer[offset..offset + data_1.len()].copy_from_slice(data_1);
offset += data_1.len();
// Write data2 (remainder - no length prefix needed)
buffer[offset..].copy_from_slice(data_2);
Ok(())
}
}Зміна розміру облікового запису
Щоразу, коли ви оновлюєте динамічне поле, якщо розмір змінюється, ви повинні змінити розмір облікового запису. Ось універсальна функція для зміни розміру облікового запису:
pub fn resize_account(
account: &AccountInfo,
payer: &AccountInfo,
new_size: usize,
zero_out: bool,
) -> ProgramResult {
// If the account is already the correct size, return early
if new_size == account.data_len() {
return Ok(());
}
// Calculate rent requirements
let rent = Rent::get()?;
let new_minimum_balance = rent.minimum_balance(new_size);
// Adjust lamports to meet rent-exemption requirements
match new_minimum_balance.cmp(&account.lamports()) {
core::cmp::Ordering::Greater => {
// Need more lamports for rent exemption
let lamports_diff = new_minimum_balance.saturating_sub(account.lamports());
**payer.try_borrow_mut_lamports()? -= lamports_diff;
**account.try_borrow_mut_lamports()? += lamports_diff;
}
core::cmp::Ordering::Less => {
// Return excess lamports to payer
let lamports_diff = account.lamports().saturating_sub(new_minimum_balance);
**account.try_borrow_mut_lamports()? -= lamports_diff;
**payer.try_borrow_mut_lamports()? += lamports_diff;
}
core::cmp::Ordering::Equal => {
// No lamport transfer needed
}
}
// Reallocate the account
account.resize(new_size)?;
Ok(())
}