키보드를 만듭시다. 어때요~ 참 쉽죠? (11)
MSC 스펙을 구현해서 디바이스를 대용량 저장장치로 연결하면 장치로 연결까지는 되는데 드라이브로 마운트는 되지 않는다. 윈도우면 E:, F: 이렇게 드라이브로 뜨지 않는다는 말이고 리눅스면 /mnt나 /media 디렉터리 밑에 뭐가 생기지 않는다는 말이다. 호스트에서 디바이스로 파일을 보내는 실질적인 작업을 하려면 드라이브로 마운트를 해야 한다. 그러려면 디바이스에 파일 시스템을 만들어야 한다.
디바이스에 파일 시스템을 만드는 방법은 두 가지 방법이 있다. 디바이스 스토리지 영역을 실제로 호스트에 공개해서 호스트에서 포멧하는 방법. 디바이스에서 파일 시스템에 해당하는 데이터를 호스트로 보내서 호스트가 이 디바이스는 이미 파일 시스템이 있다고 인식하게 만드는 방법. STM32F103은 스토리지라고 해 봐야 인터널 플래시 64KB ~ 128KB 뿐이고 내가 MSC를 구현한건 호스트에서 파일을 받기 위함이지 디바이스 내부의 바이너리 데이터를 호스트로 보내려고 하는 것은 아니다. 그래서 굳이 내가 호스트에서 디바이스 스토리지 영역을 제어하게 할 필요는 없다.
FAT16
왜 FAT16을 선택했냐면 예제가 많고 쉬워서다. 도스 시절 플로피 디스크를 쓸 때부터 있던 파일 시스템이라 구조가 간단하다. 문서 한 번만 읽어도 이해할 수 있다. 이게 가장 큰 장점이다. 그러다 보니 예제가 많다. 다른 사람들도 나랑 똑같은 거다. 그 사람들도 쉽고 이해하기 쉬우니까 FAT16을 만들고 소스를 공개한 것이다. 그리고 거의 모든 운영체제에서 별다른 별도 설정없이 사용할 수 있다. 굳이 리눅스 커널을 빌드 할 때 옵션에서 빼지 않는한 FAT16은 리눅스에서 잘 동작한다. 윈도우에서도 두말할 필요없이 잘 동작한다. 맥에서도 잘 된다고 한다.
FAT16에 대해 찾아보면 당연히 영어 자료도 많고 한글 자료도 많다. 내 개인적인 생각인데 의외로 한글 자료들이 더 충실하고 잘 정리된 것들이 많았다. 그래서 FAT16 구조가 궁금하신 분들은 그냥 처음부터 한글 자료로 봐도 충분하리라 생각한다.
구글 검색만해도 10초도 안걸려 찾을 수 있는 자료를 가져와서 다시 설명할 필요는 없다고 본다. 그래서 자세한 설명은 하지 않고 주요 키워드와 어느부분을 고쳐야 하는지만 이 글에서 다뤄도 충분할 것이다.
어차피 FAT16을 바닥부터 작성하진 않을 것이기 때문에 뭐가 뭔지만 알면된다. 그래서 FAT16을 이해 하려면 아래 네 개만 알면된다.
- 부트 레코드 (Boot Record)
- 바이오스 파라메터 블록 (BIOS Parameter Block : BPB)
- 파일 얼로케이션 테이블 (File Allocation Table : FAT)
- 루트 디렉터리 섹터(Root directory sector)
왜 이들 네 개만 알면 되냐면, 운영체제가 FAT16으로 드라이브를 마운트할 때 읽는 최소 정보기 때문이다. 최소한 이 네 개만 운영체제로 보내주면 운영체제는 드라이브를 마운트하고 우리는 운영체제 있는 파일 매니저(탐색기)로 디바이스에 접근할 수 있다.
어딜 고쳐야 하는가
고쳐야 할 곳이 많지도 않다. 사실 인터넷에서 쉽게 구할 수 있는 가상 FAT16 구현 코드를 그대로 써도 아마 어지간해서는 동작할 것이라고 생각한다. 물론 SCSI에서 호출하는 함수 이름은 맞춰 줘야 한다.
const uint8_t FAT16_BootSector[FATBootSize]= { 0xEB, /*00 - BS_jmpBoot */ 0x3C, /*01 - BS_jmpBoot */ 0x90, /*02 - BS_jmpBoot */ 'M','S','D','O','S','5','.','0', /* 03-10 - BS_OEMName */ 0x00, /*11(0B) - BPB_BytesPerSec = 1024 */ 0x04, /*12(0C) - BPB_BytesPerSec = */ 0x08, /*13(0D) - BPB_Sec_PerClus = 8 sec (1024 * 8 = 8K)*/ 1, /*14(0E) - BPB_RsvdSecCnt = 1 */ 0, /*15(0F) - BPB_RsvdSecCnt = */ 2, /*16(10) - BPB_NumFATs = 2 */ 0x00, /*17(11) - BPB_RootEntCnt = 512 */ 0x02, /*18(12) - BPB_RootEntCnt = */ 0, /*19(13) - BPB_TotSec16 = 0 */ 0, /*20(14) - BPB_TotSec16 = 0 */ 0xF8, /*21(15) - BPB_Media = 0xF8 */ 0x01, /*22(16) - BPBFATSz16 = 0x0001 */ 0, /*23(17) - BPBFATSz16 = */ 0x3F, /*24(18) - BPB_SecPerTrk = 0x003F */ 0, /*25(19) - BPB_SecPerTrk = 0x003F */ 0xFF, /*26(1A) - BPB_NumHeads = 255 */ 0, /*27(1B) - BPB_NumHeads = 255 */ 0, /*28(1C) - BPB_HiddSec = 0 */ 0, /*29(1D) - BPB_HiddSec = 0 */ 0, /*30(1E) - BPB_HiddSec = 0 */ 0, /*31(1F) - BPB_HiddSec = 0 */ 0x00, /*32(20) - BPB_TotSec32 = */ 0xC8, /*33(21) - BPB_TotSec32 = 0x0000C800 25MB*/ 0x00, /*34(22) - BPB_TotSec32 = */ 0x00, /*35(23) - BPB_TotSec32 = */ 0x80, /*36(24) - BS_DrvNum = 0x80 */ 0, /*37(25) - BS_Reserved1 = 0 , dirty bit = 0*/ /* Updated from FSL*/ 0x29, /*38(26) - BS_BootSig = 0x29 */ 0xBD, /*39(27) - BS_VolID = 0x02DDA5BD */ 0xA5, /*40(28) - BS_VolID = 0x02DDA5BD */ 0xDD, /*41(29) - BS_VolID = 0x02DDA5BD */ 0x02, /*42(2A) - BS_VolID = 0x02DDA5BD */ 'N','O',' ','N','A','M','E',' ',' ',' ',' ', /*43-53 - BS_VolLab */ 'F','A','T','1','6',' ',' ',' ' /*54-61 (36-3D) - BS_FilSysType */ };
위 코드는 FAT16 부트레코드다. 숫자만 쭉 나열되어 있다. 그 의미는 옆에 친절한 주석이 설명한다. 이 중 주석에 BPB_로 시작하는 이름이 달린 숫자들이 바이오스 파라메터 블록에 해당하는 숫자들이다. 실제 디스크의 물리적 정보가 주 내용이다. 섹터 개수라든가 섹터 크기 같은 것들이다. 그리고 오래된 파일 시스템이다 보니 헤드, 실린더, 트랙 이런 용어도 보인다.
다른 부분은 고치지 않았고, BPB_BytesPerSec 필드만 1024로 바꿨다. 왜냐면 STM32F103의 내장 플래시 메모리 페이지 사이즈가 1KB라서 그 크기를 같게 맞췄다. 꼭 섹터 사이즈를 플래시 메모리 페이지 사이즈와 같게 맞출 필요는 없다. 다만 배수나 2로 나눠서 떨어지는 숫자로 하는 것이 좋다.
어떤 동작을 기대하는가
이제 소스 코드를 보고 이 코드가 어떻게 동작할지 예측해보자. 사용자가 가장 먼저 하는 일은 뭘까? 당연히 USB 케이블을 USB 소켓에 꼽는 일이다. 그러면 운영체제에서 USB 장치를 인식하고 장치 인식이 다 끝나면 대용량 저장 장치라는 것을 알았으니 드라이브 마운트를 시도할 것이다.
드라이브 마운트 과정에서 딱히 쓰기(write) 동작이 있을 것 같진 않다. 왜냐면 일단 드라이브 정보를 읽어야 마운트를 할테니 미리 약속된 주소에서 정보를 읽으려 할 것이다. 논리적으로 생각해보면 이렇고 실제로도 읽기(read) 동작만으로 운영체제는 마운트 절차를 진행한다.
읽기 동작을 하는 코드를 보자.
int8_t STORAGE_Read_FS (uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { memclr(buf, FATBytesPerSec); int32_t i = 0; switch (blk_addr) { /* FAT Boot Sector */ case 0: for(i=0;i<FATBootSize;i++) { *buf++ = FAT16_BootSector[i]; } /* EndFor */ /* Boot Sector requires these 2 bytes at end */ buf[FATBytesPerSec - 2] = 0x55; buf[FATBytesPerSec - 1] = 0xAA; break; /* FAT Table Sector */ case 1: //FAT0 case 2: //FAT1 /* Write FAT Table Sector */ for(i=0;i<FATTableSize;i++) { *buf++ = FAT16_TableSector0[i]; } /* EndFor */ break; /* Root Directory Sector */ case 3: for(i=0;i<FATFileNameSize;i++) { *buf++ = FAT16_ReadyFileName[i]; } /* EndFor */ /* Write rest of file FAT structure */ for(i=0;i<FATDirSize;i++) { *buf++ = FAT16_RootDirSector[i]; } /* EndFor */ break; /* All other sectors empty */ default: break; } /* EndSwitch */ return (USBD_OK); }
파라메터로 넘어 오는 blk_addr이 섹터 주소다. SCSI 레이어에서 USB 레이어에서 넘어 오는 바이트 오프셋 주소를 섹터 오프셋 주소로 바꿔준다.
if( ((USBD_StorageTypeDef *)pdev->pUserData)->Read(lun , hmsc->bot_data, hmsc->scsi_blk_addr / hmsc->scsi_blk_size, len / hmsc->scsi_blk_size) < 0)
위 코드로 SCSI 레이어에서 FAT16으로 주소 형태를 변경한다. hmsc->scsi_blk_addr는 USB 레이어를 통해 넘어온 바이트 오프셋 주소값이다. hmsc->scsi_blk_size는 개발자가 넣어주는 블럭 사이즈인데 섹터 사이즈를 지정하면 된다.
STORAGE_Read_FS() 함수를 보면 switch-case 구문으로 섹터 오프셋 0, 1, 2, 3을 처리한다. 섹터 오프셋 0은 부트 레코드다. 섹터 오프셋 1, 2는 FAT 영역이다. 위에 바이오스 파라메터 블록을 보면 BPB_NumFATs = 2가 보인다. FAT가 섹터 2개를 차지한다고 설정했기 때문에 부트 레코드에 바로 이어서 FAT 영역이 나오고 개수는 두 개다. 섹터 오프셋 3은 루트 디렉터리 섹터다. 오프셋 3을 읽어서 드라이브 루트 디렉터리 목록을 보여주는 거다.
static uint8_t FAT16_ReadyFileName[FATFileNameSize]= { 'R','E','A','D','Y',' ',' ',' ','T','X','T' /*00-10 - Short File Name */ };
섹터 오프셋 3을 읽으면 FAT16_ReadyFileName에서 데이터를 읽어서 호스트로 보낸다. 내용은 위와 같다. 그래서 대용량 저장 장치로 인식하고 마운트된 드라이브를 열면 READY.TXT 파일이 루트 디렉터리에 있다.
그리고 사용자가 드라이브로 키맵 파일이나 펌웨어 파일을 복사할 것이다. 그러면 이제 쓰기(write) 동작을 시작한다. 쓰기 함수는 아래와 같다.
int8_t STORAGE_Write_FS (uint8_t lun, uint8_t *buf, uint32_t blk_addr, uint16_t blk_len) { debug_printf("W:%x %x\n", blk_addr, blk_len); switch(blk_addr) { case 0: // Boot Sector case 1: // FAT0 case 2: // FAT1 break; case 3: // 16K Root Directory FAT_RootDirWriteRequest(blk_addr, buf, blk_len); break; default: { //if(blk_addr >= 0x12000) { FAT_DataSectorWriteRequest(blk_addr, buf, blk_len); } } break; } return (USBD_OK); }
파일 데이터만 관심있기 때문에 부트 레코드나 FAT 섹터 영역을 수정하는 호스트 요청은 무시한다. 호스트 입장에서 키맵 파일이나 펌웨어 바이너리를 대용량 저장장치의 루트 디렉터리에 복사하는 것이기 때문에 호스튼는 대용량 저장 장치의 루트 디렉터리에 쓰기를 해서 새로운 파일 이름을 루트 디렉터리에 기록해야 한다. 그래서 섹터 오프셋 3에 대한 요청은 FAT_RootDirWriteRequest() 함수를 호출해서 처리한다. 섹터 오프셋 0, 1, 2, 3을 제외한 섹터 오프셋 접근은 데이터 영역 접근이다. 이 데이터가 우리가 관심을 가지는 것이므로 FAT_DataSectorWriteRequest() 함수를 호출해서 처리한다.
static uint32_t FAT_DataSectorWriteRequest(uint32_t FAT_LBA,uint8_t* data, uint32_t len) { uint32_t tempflashMaxLen = MAIN_FW_FLASH_SIZE * FLASH_PAGE_SIZE; //30KB // 0x1A = RootDirSectors (16) + FAT1 sectors(1) + FAT2 sectors(1) + FirstClusterSectors(8) = 26 (0x1A) if (FAT_LBA > 0x1A) { if (filesize_total > tempflashMaxLen) { debug_printf(">>> Large\n"); FATSetStatusFileName("LARGE "); return len; } if (tempflashStartPage == 0) { tempflashStartPage = MAIN_FW_PAGENUM; } if (tempflashStartPage != 0) { Flash_write_page(data, tempflashStartPage); tempflashStartPage++; filesize_total += FATBytesPerSec; } if ((FileAttr.DIR_FileSize != 0) && (filesize_total >= FileAttr.DIR_FileSize)) { FATSetStatusFileName("SUCCESS "); return len; } } }
실제 코드는 키맵과 펌웨어 바이너리를 매직 스트링으로 구분하고 각각 다른 플레시 메모리 페이지 주소로 저장되는 등 부가 코드가 더 있다. 하지만 핵심만 추리면 위와 같다.
과정을 간략히 설명하면, 호스트가 요청한 섹터 오프셋이 0x1A보다 크면 데이터 영역이다. 데이터 영역에 요청이 오면 현재까지 기록한 페이지 주소를 증가하면서 플래시 메모리에 호스트에서 받은 데이터를 쓴다. 그러다가 데이터가 30KB를 넘으면 아까 있던 READY.TXT를 LARGE.TXT로 바꾼다. 쭉쭉 데이터를 쓰다가 전송이 완료되면 READY.TXT를 SUCCESS.TXT로 바꾼다.
실제로 해 보면?
실제로 해 보면 거의 잘 동작하는데 마지막 부분이 내가 기대했던것과 달랐다.
대용량 저장 장치로 키보드를 연결하면 위 그림처럼 루트 디렉터리에 READY.TXT가 보인다. 여기까지는 기대한 대로 동작한 거다. 그리고 이제 펌웨어 바이너리를 복사해 넣는다.
그러면 펌웨어 바이너리가 복사된다. 펌웨어 내부에서는 READY.TXT를 SUCCESS.TXT로 바꿨는데 실제 호스트에서는 보이지 않는다.
언마운트를 했다가 다시 마운트 해야 파일 이름이 바뀐다. 왜냐면 운영체제가 캐싱을 하기 때문이다. 안타깝지만 그냥 이렇게 쓰기로 했다. 딱히 심하게 불편한 것도 아니고, 원하는 대로 실시간으로 바뀌게 한다 한들 동작에 큰 영향을 미치는 것도 아니다. 그냥 보기 좋은 기능일 뿐이니까.
이렇게 다시 마운트하면 캐싱 데이터가 없어지고 호스트가 다시 루트 디렉터리 정보를 요청하기 때문에 SUCCESS.TXT로 파일 이름이 바뀐 것을 볼 수 있다. 그리고 아까 복사했던 fw.bin 파일 이름은 보이지 않는다. 당연하다. 펌웨어에서 파일 이름은 처리하지 않기 때문이다. 파일 데이터만 처리한다.
첨부 | 파일 크기 |
---|---|
ready.png | 15.89 KB |
fw.png | 20.32 KB |
success.png | 15.74 KB |
댓글 달기