Thursday, July 7, 2022

SD Memory Card Library for AVR Microcontrollers - SD and FAT Driver

This project facilitates the reading and writing of SD flash memory cards using FAT16 or FAT32 file systems, over the SPI protocol, and it is designed with embedded systems in mind. It includes an SD driver that uses the SPI interface and a FAT driver that is controlled by the host microcontroller.

There are two main standards for flash memory cards: MMC (MultiMediaCards) and SD (Secure Digital), SD being the most used today. SD was developed by the SD Association (SDA) as an improvement over MMCs. These cards have basically a flash memory array and a microcontroller inside that controls erasing, reading, writing, error controls, and wear leveling of the flash array. The data is transferred between the memory card and the host controller as data blocks in units of 512 bytes.

The recommended file systems for memory cards are FAT12/16/32 or exFAT. Maximum volume sizes for each one are: 256MB, 4GB, 16TB* for FAT32 and 128PB for exFAT.

*Windows will refuse to format cards over 32GB using FAT32, offering exFAT and NTFS as an option but there are workarounds and third-party software that can do this.


FAT16 FAT32

Flash RAM Flash RAM
Read 6.1k 605 6.6k 607
Write 7.8k 607 8.6k 609
Read/Write 7.9k 607 8.7k 609

Some rounded up values of the code footprint depending on the functions used: for reading, writing or both. RAM size is expressed in bytes and it includes 512 bytes for the read/write buffer.

Features

  • Communication protocol: SPI
  • Supported memory cards: SD cards (MMC's are not implemented)
  • Includes SD driver: Yes
  • Supported file systems: FAT16 and FAT32
  • Support for LFN (Long File Names): Yes (up to 255 characters)
  • Formatting utility: No
  • CRC: not yet
  • Multiple files and folders instances: Yes
  • Ability to create folders and directories
  • Delete files: No (only file truncation is implemented)
  • FSInfo (FAT32): not implemented in order to reduce the code size. The only situations when this could be a downside is when querying for free space, creating files/folders or expand them since this is when it is necessary to search for a free cluster. If the card is mostly empty, even if it has a large capacity, the search for a free cluster will be very quick. As the card is filled it will take longer (few seconds).

Contents

  1. SD Card Pins
  2. Schematic Interface
  3. Return Values
  4. File Object Structures
  5. File names
  6. The Buffer, the Writing and the Flush
  7. Code Examples
  8. Library Configuration
  9. Functions and their usage
    1. Volume Management
    2. Directory Access
    3. File Access
  10. Download SD Card Library

SD Card Pinout

Both MMC/SD standards have their own proprietary protocols but they also support SPI which can be selected during card initialization. Since microcontrollers have SPI integrated hardware, this is the most used interface for memory cards.


MMC-SD-miniSD-microSD-Color-Numbers-Names

SDC miniSD - SD
- SPI
PIN SD SPI SD SPI Description
1 DAT3 CS DAT3 CS - SD Serial Data 3
- SPI Card Select (active Low)
2 CMD DI CMD DI - Command, response
- SPI Serial Data In (MOSI)
3 VSS VSS Ground
4 VDD VDD Power. Usually 2.7 to 3.6V and minimum 100mA.
Not 5V tolerant!
5 CLK CLK Serial Clock
6 VSS VSS Ground
7 DAT0 DO DAT0 DO - SD Serial Data 0
- SPI Serial Data Out (MISO)
8 DAT1 NC DAT1 NC - SD Serial Data 1
- Unused
9 DAT2 NC DAT2 NC - SD Serial Data 2
- Unused
10
NC Not Connected
11
NC Not Connected

microSD - SD
- SPI
PIN SD SPI Description
1 DAT2 NC - SD Serial Data 2
- Unused
2 DAT3 CS - SD Serial Data 3
- SPI Card Select (active Low)
3 CMD DI - Command, response
- SPI Serial Data In (MOSI)
4 VDD Power. Usually 2.7 to 3.6V and minimum 100mA.
Not 5V tolerant!
5 CLK Serial Clock
6 VSS Ground
7 DAT0 DO - SD Serial Data 0
- SPI Serial Data Out (MISO)
8 DAT1 NC - SD Serial Data 1
- Unused

 

microSD Card Schematic Interface

microSD Memory Card Schematic Interface

The microcontroller and the memory card must use the same voltage. If the microcontroller is powered from 5V and the card is on 3.3V there needs to be a logic voltage level shifter such as 74AHC125D.

According to the SD card specification, unused Data lines on the card must be terminated with pull-up resistors. Floating pins can lead to excessive power consumption. 

A pull-up on the DO line is needed because, until initialized into SPI mode, the DO pin is open drain.

A pull-up on the CS (Card Select) is recommended because it could take many milliseconds until the microcontroller enables the CS pin High and during that time the SPI pins could have random values and possibly corrupting the card.

Some sockets have a 9th pin that optionally can be pulled high using a microcontroller pin set as an input high. This is a mechanical switch. When the card is inserted the pin goes Low allowing the microcontroller to detect card insertion/removal.

Return Values

Two enums are used for the return values: first enum is only used by the volume initialization function, while the second one is used by most of the functions.

/* Volume mounting return codes (MOUNT_RESULT) */
typedef enum {
	MR_OK = 0,		// Succeeded
	MR_DEVICE_INIT_FAIL,	// An error occurred during device initialization
	MR_ERR,			// An error occurred in the low level disk I/O layer
	MR_NO_PARTITION,	// No partition has been found
	MR_FAT_ERR,		// General FAT error
	MR_UNSUPPORTED_FS,	// Unsupported file system
	MR_UNSUPPORTED_BS	// Unsupported block size
} MOUNT_RESULT;


/* File function return codes (FRESULT) */
typedef enum {
	FR_OK = 0,		// Succeeded
	FR_EOF,			// End of file
	FR_NOT_FOUND,		// Could not find the file
	FR_NO_PATH,		// Could not find the path
	FR_NO_SPACE,		// Not enough space to create file
	FR_EXIST,		// File exists
	FR_INCORRECT_ENTRY,	// Some entry parameters are incorrect
	FR_DENIED,		// Access denied due to prohibited access or directory full
	FR_PATH_LENGTH_EXCEEDED,// Path too long
	FR_NOT_A_DIRECTORY,
	FR_ROOT_DIR,		// When going back the directory path this is returned when the active dir is root
	FR_INDEX_OUT_OF_RANGE,
	FR_DEVICE_ERR		// A hard error occurred in the low level disk I/O layer
} FRESULT;
 

File Object Structures

The properties of an active directory or file are stored by two different object structures called DIR and FILE. Most functions require a pointer to one or both of them.

Example:

DIR folder;
FILE file;

FAT_function(&folder, &file);

To open multiple folders and files at the same time, create other instances like so:

DIR folder;
FILE file;
DIR imgFolder;
FILE imgFile;

FAT_function(&folder, &file);
FAT_function(&imgFolder, &imgFile);

But this will increase the memory usage and could create confusion with which file belongs to which directory.

Filenames

Unicode support is not implemented because that would take too much space on a microcontroller.

A file name can't contain any of the following characters or Windows might complain when the card is used: \ / : * ? " < > |

The Buffer, the Writing and the Flush

Main buffer and writing functions

The data is transferred between the memory card and the host controller as data blocks in units of 512 bytes. That is because High Capacity SD Memory Cards (over 2GB) have the block length fixed to 512 bytes. On older cards, the block length could be changed but since they are obsolete and to keep things consistent, the SD card library has also the buffer length set to 512 bytes. FAT file systems also like to use blocks of 512 bytes.

The main buffer called SD_Buffer is used for both reading and writing and that could create problems if the write functions are not used properly. It would be nice to have two separate buffers - one for reading and one for writing - but that would take double the memory and some project could not afford that.

When using a write function, the data is not immediately transferred to the card because that would wear the flash memory very quickly (imagine writing a sector after each byte). Instead the writing functions puts the data into the main buffer array. When the buffer is full the function will automatically transmit the buffer to the card. The buffer could also be saved to the card at any time by using fsync(). It is important to remember that between using a writing function and fsync() no function that uses the SD_Buffer must be used because they will overwrite the buffer leading to data loss. To avoid that, simply use the writing functions like this:

                // Write a string
		FAT_fwriteString(&file, "Logging Date: 2022\n");
			
		// Write sensor output
		FAT_fwriteFloat(&file, 120.033, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, -0.221, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, -30.004, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, 0.023, 3);
			
		// Synchronize the writing buffer with the card
		FAT_fsync(&file);

Notice that between the writing functions and fsync(), no other function is used.

Data synchronization

The fsync() is used to synchronize the data written to main buffer with the card. Other applications calls this flush() and you can rename it like this if you wish.

Knowing that writing the flash memory too often has a negative impact, you may ask how often should the fsync() function be used. That depends on some factors. For example in data logging: how important is the data? Could you afford the loss of data collected over 5 minutes for example? How often is the data collected? How likely is for the power to the micro or card to go down? If you log a temperature sensor once an hour then you could use fsync() every hour. Ideally you would write to a card as less often as possible. Say you write every 5 minutes, that is over 100k writes over a year. Writing once a minute means 525k in a year. Considering that a good card has wear level protection, that might not affect the card as much. I am not an expert in flash memory so I prefer to play safe and write the card as less often as possible.

When the buffer gets filled it will be written to the card automatically but the file size will only be updated when using fsync(). This way you could write many block without writing the sector that stores the file size. This sector contains other directory entries of other files so it will see the most wear if the file size it is updated too often.

To keep track of time to know when to use fsync() you don't need anything precise such a crystal or RTC. You can easily implement a simple clock using this millis library https://www.programming-electronics-diy.xyz/2021/01/millis-and-micros-library-for-avr.html.

Code Examples

Writing - data logging CSV style

#include "fat.h"

DIR dir;
FILE file;
uint8_t return_code = 0;
uint8_t dirItems = 0;
	
// Mount the memory card
return_code = FAT_mountVolume();
	
// If no error
if(return_code == MR_OK){
	// Make a directory
	FAT_makeDir("/Logging");
		
	// Open folder
	return_code = FAT_openDir(&dir, "/Logging");
	if(return_code == FR_OK){
		// ... optionally print folder name using an LCD library: print(FAT_getFilename());
		
		// Get number of folders and files inside the directory
		dirItems = FAT_dirCountItems(&dir);
			
		// Open a file for reading or writing
		return_code = FAT_fopen(&dir, &file, "log.txt");
			
		// Make the file if it doesn't exist
		// The functions that make folders and files takes much space
		// so it is recommended to have them made on a PC instead
		if(return_code == FR_NOT_FOUND){
			FAT_makeFile("/Logging/log.txt");
			return_code = FAT_fopen(&dir, &file, "log.txt");
		}
			
		// Move the writing pointer to the end of the file
		FAT_fseekEnd(&file);
			
		// Write a string
		FAT_fwriteString(&file, "Logging Date: 2022\n");
			
		// Write sensor output
		FAT_fwriteFloat(&file, 120.033, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, -0.221, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, -30.004, 3);
		FAT_fwriteString(&file, ",");
		FAT_fwriteFloat(&file, 0.023, 3);
			
		// Synchronize the writing buffer with the card
		FAT_fsync(&file);
	}
}

Reading

#include "fat.h"

DIR dir;
FILE file;
uint8_t return_code = 0;
uint8_t dirItems = 0;
uint8_t* read_buf = 0;
	
// Mount the memory card
return_code = FAT_mountVolume();
	
// If no error
if(return_code == MR_OK){
		
	// Open folder
	return_code = FAT_openDir(&dir, "/Logging");
	if(return_code == FR_OK){
		// ... optionally print folder name using an LCD library: print(FAT_getFilename());
		
		// Get number of folders and files inside the directory
		dirItems = FAT_dirCountItems(&dir);
			
		// Open a file for reading or writing
		return_code = FAT_fopen(&dir, &file, "log.txt");
		
		// Read block of 512 bytes until the end-of-file flag is set
		while(FAT_feof(&file) == 0 && FAT_ferror(&file) == 0){
			read_buf = FAT_fread(&file);
			
			// Function included in DIS_ST7735.h LCD library
			DIS_ST7735_drawString((char*)read_buf);
		}
		
		// Move file read pointer back to the beginning (optional)
		FAT_fseek(&file, 0);
	}
}

Library Configuration

SPI Pins (sd.h)

By default the SPI pins are set to be on port PORTB. The MOSI pin is on PB3 and SCK on pin PB5. If you are using Arduino you need to find what microcontroller it's using and check it's datasheet for the SPI pins. Some microcontrollers have two SPI interfaces. SPI0 is used by this library. This should work for ATmega328. The MISO is configured automatically.

// SPI0 I/O pins (MOSI and SCK)
#define SPI0_PORT	PORTB // SPI PORT
#define SPI0_DDR	DDRB  // SPI DDR
#define SPI0_PINS	PINB  // Holds the state of each pin on port where SPI pins are

#define SPI0_MOSI_PIN	PB3   // SDA pin
#define SPI0_SCK_PIN	PB5   // SCK pin

The CS (Card Select) is the SPI SS0 pin and is set to PB2 but can be changed to any other port and pin. Regardless of what CS pin you use it is important that the SS SPI pin be set as an output high or low otherwise the SPI won't work properly. This is according to the datasheet.

// SPI Chip Select pin
#define CARD_CS_DDR		DDRB
#define CARD_CS_PORT		PORTB
#define CARD_CS_PIN		PB2

Card detect pin  (sd.h)

Most microSD card sockets have a 9'th mechanical pin used to detect the card presence. This pin is optional.

// Card detect pin for card sockets with this feature (optional)
#define CARD_DETECT_DDR		DDRB
#define CARD_DETECT_PORT	PORTB
#define CARD_DETECT_PIN		PB1
#define CARD_DETECT_PINS	PINB

Use FAT32 (fat.h)

To disable FAT32 and use only FAT16, set this to 0.

#define FAT_SUPPORT_FAT32	1

Maximum filename length (fat.h)

This can be changed depending on how long of a filename you need. Maximum value is 255 but that includes the path and filename.

#define FAT_MAX_FILENAME_LENGTH		30

Functions and their usage

Volume Management

Card Initialization

Reads the MBR (if present) and the Boot Sector and sets the fat object structure with all the proprieties of the card file system, necessary by all other functions. If there are multiple partitions, it selects the first one. The host will negotiate for the SPI protocol. The SPI clock will be set at the maximum speed which is F_CPU / 2.

MOUNT_RESULT FAT_mountVolume(void)

Parameters: none

Return Values: MR_OK or error code

Example:

uint8_t return_code = 0; // Mount the memory card return_code = FAT_mountVolume(); // If no error if(return_code == MR_OK){     // ... open folder }

Volume Free Space

Returns volume free space in bytes. On FAT32 this can take a few seconds depending on how large the card is.

uint64_t FAT_volumeFreeSpace(void)

Volume Capacity in Bytes

Returns volume capacity in bytes.

uint64_t FAT_volumeCapacity(void)

Volume Capacity in KiB

Returns volume capacity in KiB.

float FAT_volumeCapacityKB(void)

Volume Capacity in MiB

Returns volume capacity in MiB.

float FAT_volumeCapacityMB(void)

Volume Capacity in GiB

Returns volume capacity in GiB.

float FAT_volumeCapacityGB(void)

Read Volume Label

Returns the label and serial number of a volume.

FRESULT FAT_getLabel(char* label, uint32_t* vol_sn)

Parameters

label 

Pointer to the buffer to store the volume label. If the volume has no label, a null-string will be returned. The buffer array must be of type char and 12 bytes in size: 11 for volume label and 1 for the null.

vol_sn

 Pointer to a uint32_t variable to store the volume serial number. Pass 0 if not needed.
Example

char label[12]; uint32_t vol_sn = 0; FAT_getLabel(label, &vol_sn)

 

Directory Access

Make Directory

Create a subdirectory at the specified path. Name of the subdirectory is the name after the last slash '/' in path.

FRESULT FAT_makeDir(const char* path)

Parameters

path

Directory path and name
Example

uint8_t return_code = 0; return_code = FAT_makeDir("/Folder 1/Folder 2"); if(return_code == FR_OK){ // Folder 2 created inside Folder 1 // Folder 1 must exist } // Make a folder inside ROOT FAT_makeDir("/Folder 3");
 

Open Directory

Open a directory using the given path. The path may start with '/' but is not necessary. A single slash '/'  indicates the root directory. The path must not end with a slash.

FRESULT FAT_openDir(DIR* dir_p, const char* path)

Parameters

dir_p

Pointer to the directory object structure

path

Directory path and name

Open a Directory by Index

When the directory name is unknown, another way to open it is by index. The index is the order the files appears in the directory entries. When you will need this function you will know it. Can be used for a file explorer for example.

FAT_openDir() must be used before running this function.

FRESULT FAT_openDirByIndex(DIR* dir_p, FILE* finfo_p, uint16_t idx)

Parameters

dir_p

Pointer to the directory object structure

finfo_p

Pointer to the file object structure

idx

The file index starting from 1

Go Back

Go to parent directory of active directory. If the active directory is Root, then the function will return FR_ROOT_DIR and active directory will remain Root.

FRESULT FAT_dirBack(DIR* dir_p)

Parameters

dir_p

Pointer to the directory object structure

Find by Index

Get file info of the item in the active directory with a specific index position. If index is greater than the number of items inside the directory, then FR_NOT_FOUND will be returned.

FRESULT FAT_findByIndex(DIR* dir_p, FILE* finfo_p, uint16_t idx)

Parameters: see FAT_openDirByIndex()

Find Next

Find next file in the active directory and get file info.

FRESULT FAT_findNext(DIR* dir_p, FILE* finfo_p)

Parameters

dir_p

Pointer to the directory object structure

finfo_p

Pointer to the file object structure

Count Items

Return the total number of files and folders inside the active directory.

uint16_t FAT_dirCountItems(DIR* dir_p)


File Access

Make File

Create a file at the specified path.

FRESULT FAT_makeFile(const char* path)

Parameters: see FAT_makeDir()

Open File

Open a file by name. The search will be made inside the active directory.

FRESULT FAT_fopen(DIR* dir_p, FILE* file_p, char *file_name)

Parameters

dir_p

Pointer to the directory object structure

file_p

Pointer to the file object structure

file_name

Name of the file
Example

DIR dir; FILE file; FAT_openDir(&dir, "/Logs"); // Open a file inside the "Logs" folder FAT_fopen(&dir, &file, "log.txt"); 

Open File by Index

When the file name is unknown, another way to open it is by index. The index is the order the files appears in the directory entries. When you will need this function you will know it. Can be used for a file explorer for example. A directory must be open first.

FRESULT FAT_fopenByIndex(DIR* dir_p, FILE* file_p, uint16_t idx)

Parameters

dir_p

Pointer to the directory object structure

file_p

Pointer to the file object structure

idx

The file index starting from 1

Set File Pointer

Move the file pointer x number of bytes.

void FAT_fseek(FILE* fp, FSIZE_t fptr)

Parameters

fp

Pointer to the file object structure

fptr

File pointer offset value in bytes from 0 to file size

Seek File End

Move the file pointer to end of file. Wrapper of fseek().

void FAT_fseekEnd(FILE* fp)

Get File Pointer

Return the fptr file pointer. FSIZE_t is uint32_t.

FSIZE_t FAT_getFptr(FILE* fp)
 

Write Float Number

Wrapper function of fwrite() used to convert a float number into a string and write it to a file. A file must be open first.

FRESULT FAT_fwriteFloat(FILE* fp, float float_nr, uint8_t decimals)

Parameters

fp

Pointer to the file object structure

float_nr

The float number
decimals

Number of digits after the dot (float precision)

Write Number

Wrapper function of fwrite() used to convert a number into a string and write it to a file. A file must be open first.

FRESULT FAT_fwriteInt(FILE* fp, INT_SIZE nr)

Parameters

fp

Pointer to the file object structure

nr

The number. By default INT_SIZE is of type int32_t but can be changed to int64_t if very large numbers need to be written.

Write String

Wrapper function of fwrite() used to write a string. A file must be open first.

FRESULT FAT_fwriteString(FILE* fp, const char* string)

Parameters

fp

Pointer to the file object structure

string

String of characters
Example

FAT_fwriteString(&fp, "String to SD card file")

Write File

Write data to the file at the file offset pointed by read/write pointer. The file must be open first. The write pointer advances with each byte written. CAUTION: running other functions will overwrite the common data buffer causing the loss of  unsaved data. Use fsync() before using any other function including fseek().

FRESULT FAT_fwrite(FILE* fp, const void* buff, uint16_t btw, uint16_t* bw)

Parameters

fp

Pointer to the file object structure

buff

Pointer to the data to be written
btw

Number of bytes to write
bw

Pointer to the variable to return number of bytes written
Example

#define DATA_BUFFER_SIZE 10 FILE file; uint8_t data[DATA_BUFFER_SIZE]; uint16_t bw = 0; // Move write pointer to end of the file FAT_fseekEnd(&file); FAT_fwrite(&file, data, DATA_BUFFER_SIZE, &bw); FAT_fsync(&file);

Truncate File

Truncates the file size to the current file read/write pointer set by fseek().

FRESULT FAT_ftruncate(FILE* fp)

Example

// Keep only first 10 bytes of the file FAT_fseek(&file, 10); FAT_ftruncate(&file);

Flush Data

Flush cached data of the writing file. For more details read "The Buffer, the Writing and the Flush" section.

FRESULT FAT_fsync(FILE* fp)

Read File

Read data from a file. Each time, the function will return a pointer to the main buffer array containing a block of data that must be used before running other functions that might overwrite the main buffer. The file must be opened using the appropriate function before it can be read.

uint8_t* FAT_fread(FILE* file_p)

Parameters

file_p

Pointer to an open file
Return

Returns a pointer to the main buffer array SD_Buffer with the length defined by SD_BUFFER_SIZE which is 512 bytes.

Example

See Reading example

End of File Check

While reading a file this function will return true if the file pointer is at the end or false otherwise.

bool FAT_feof(FILE* fp)

Error Flag Check

Check if an error occurs during file read. Returns FR_DEVICE_ERR or 0.

uint8_t FAT_ferror(FILE* fp)

Clear Error Flag

Set the error flag to 0.

void FAT_fclear_error(FILE* fp)

Get Filename

Obtain the name of the folder or file. It returns a char pointer to the FAT_filename array. Since this array is used by some other functions, the file name is only available after opening a folder/file. The length of the file name array is set by FAT_MAX_FILENAME_LENGTH. The default is 30 characters but can be modified depending on how long your file names will be.

char* FAT_getFilename(void)

Get Index

Return the index of the active item of the active directory.

uint16_t FAT_getItemIndex(DIR* dir_p)

Get File Size

Return the file size in bytes.

FSIZE_t FAT_getFileSize(FILE* finfo_p)

Get Write Date and Time

Return the date and time when the file was written.

uint16_t FAT_getWriteYear(FILE* finfo_p)
uint8_t FAT_getWriteMonth(FILE* finfo_p)
uint8_t FAT_getWriteDay(FILE* finfo_p)
uint8_t FAT_getWriteHour(FILE* finfo_p)
uint8_t FAT_getWriteMinute(FILE* finfo_p)
uint8_t FAT_getWriteSecond(FILE* finfo_p)

Get File Attributes

 

Check if item is folder

bool FAT_attrIsFolder(FILE* finfo_p)

Check if item is file

bool FAT_attrIsFile(FILE* finfo_p)

Check if item has the hidden attribute set

bool FAT_attrIsHidden(FILE* finfo_p)

Check if file/folder has the system attribute set

bool FAT_attrIsSystem(FILE* finfo_p)

Is item read only?

bool FAT_attrIsReadOnly(FILE* finfo_p)

Check if item has the archive attribute set

bool FAT_attrIsArchive(FILE* finfo_p)

Setting File Date and Time

When creating or modifying a file, the write date and time must be set. For now, the functions have some dummy values that could be substituted with real date and time from an RTC or a clock made using a crystal or the CPU itself. You could modify the functions listed below to fit your needs. This functions will be called by the FAT library when creating or modifying files, so you don't have to use them directly.

uint8_t FAT_createTimeMilli(void)

uint16_t FAT_createTime(void)

uint16_t FAT_createDate(void)

 

If you have any questions, suggestions or bugs to report, leave them in the comment section below. Consider sharing, subscribing and also donating.


Download

Version 1.0

sd.h - credits to www.rjhcoding.com for providing a good tutorial on SD card interface that was very helpful in the making of the "sd.h" library

fat.h

utils.h - needed by "fat.h"

 

External links

https://en.wikipedia.org/wiki/SD_card

No comments:

Post a Comment