Читання та запис даних
При створенні оптимізованих програм 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(())
}