本文主要介绍如何基于TinyUSB实现.uf2式文件拖拽升级

1、.uf2格式文件

1.1、.uf2格式文件介绍

.uf2 文件是一种专为微控制器设计的二进制文件格式,主要用于固件更新。UF2 格式最初是由 Microsoft 开发,旨在简化将代码刷入到各种微控制器设备的过程,特别是对于那些支持 USB 大容量存储类(MSC)的设备。

UF2 文件有几个优点:

  • 易于使用:用户通常只需将 UF2 文件拖放到通过 USB 连接显示为可移动磁盘的微控制器板上即可完成固件更新。
  • 跨平台兼容性:UF2 文件可以在 Windows、macOS 和 Linux 系统上使用。
  • 内置校验和:每个 UF2 包含一个校验和,以确保文件传输的完整性。
  • 元数据支持:UF2 文件可以包含额外的元数据,比如目标设备类型或制造商信息等。

1.2、.uf2的文件数据结构

.uf2文件的块大小为512字节,每个块由三部分组成:

  • 32字节长度的前缀信息
  • 476字节长度的数据信息
  • 1个字节的后缀符
偏移 字段名 长度(字节)
0 MagicStart0 4 0x0A324655 “UF2\n”
4 MagicStart1 4 0x9E5D5157
8 Flags 4
12 Address 4 数据存放Flash的地址
16 Payload Size 4 数据使用的长度
20 Block Index 4 序列化块号,从0开始
24 Block Number 4 文件占用块数量
28 Family ID 4 板号ID
32 data 476
508 MagicEnd 4 0x0AB16F30

Flags:
有五种标志位:

  • 0x00000001 表示非Flash,写入Flash应该跳过此块;通常用于携带调试信息或者注释等;
  • 0x00001000 表示文件
  • 0x00002000 表示存在Family ID
  • 0x00004000 表示MD5校验和存在
  • 0x00008000 表示存在拓展标签

Payload Size:
数据字节的数量是可配置的<=476,取决于微控制器上闪存页的大小(可擦除的最小大小)

  • 如果芯片页面大小>476字节,缓冲区的大小必定覆盖可以设置的任意大小;
  • 如果页面大小<=476字节,则Payload Size应该是设置成页面大小的倍数,可以在不缓冲的情况下直接写入Flash;此外目标地址也应该是页面大小的倍数;

Block Index:
当前块所属文件的块ID,一个uf2文件会有多个块组成,ID从0开始编号;

Block Number:
uf2文件占用的总块数;

Family ID:
约定的ID,一般表示单板ID或者芯片型号,如果Flags启动了Family ID,引导下载程序应该判断Family ID是否一致再写入;

2、前期准备工作

程序实现上,我们可以借鉴uf2-stm32f103这个项目,唯一有区别的是我们基于TinyUSB库实现的USB驱动,需要做适当裁剪和移植。

在移植前,我们要先确保以下几个工作:

  • 1、移植TinyUSB到自己芯片平台并跑通例程CDC+MSC;
  • 2、实现芯片片内的Flash读写和擦除等api接口;
  • 3、实现好App和Bootloader的跳转逻辑框架;

2.1、基于CDC串口命令实现App和Bootloader的跳转逻辑

本例中,我们预先定义这样一个跳转流程:

  • 在app程序下,发送"UP\r\n"指令,可以直接跳转(复位)进入Bootloader;在Bootloader下,发送"UP\r\n"指令,可以跳转(复位)进入app程序;
  • 在Bootloader下,如果app程序完整性和一致性满足,在下次重新上电的时候,自动进入app程序,不会保持Bootloader;

需要注意的是,在实际应用中,除了跳转逻辑外,app程序的完整性和一致性检查也是很重要的一环,本例使用了简单的标志位方法(实际是不安全的)跳过了这一过程;

2.2、Bootloader和App以及数据区的空间划分

App和Bootlader空间划分,本例中MCU的flash总共128KB,App分配80KB的空间,Boot分配40KB空间,剩下8KB空间为公共数据区:

区域 起始地址 大小
Bootloader 0x08000000 40KB
App 0x0800a000 80KB
Data 0x0801e000 8KB

3、Bootloader具体的实现思路

在《移植TinyUSB实现CDC+MSC》工程的基础上:

3.1、增加或实现片内Flash的驱动接口

MCU片内Flash的驱动程序一般直接在芯片底层库的基础上封装成自己惯用的接口即可,这里以STM32F1系列为例,实现了擦除、读、写API:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
/**
* @brief Check sector data is all 0xff
*
* @param sector_addr
* @return uint8
*/
static uint8 sector_is_blank(uint32 sector_addr) {
uint8 ret = E_OK;
uint8 buffer[BOARD_FLASH_PAGE_SIZE];

board_flash_read(buffer, sector_addr, BOARD_FLASH_PAGE_SIZE);
for (int i = 0; i < BOARD_FLASH_PAGE_SIZE; i++) {
if (buffer[i] != 0xFF) {
LOG(DEBUG, "READ %x\n", buffer[i]);
ret = E_NOT_OK;
// break;
}
}
return ret;
}

/**
* @brief Erase a sector, sector address is page aligned
*
* @param sector_addr
* @return uint8
*/
static uint8 flash_erase_sector(uint32 sector_addr) {

LOG(DEBUG, "Erase sector: %08lX ... \r\n", sector_addr);

FLASH_EraseInitTypeDef EraseInit;
EraseInit.TypeErase = FLASH_TYPEERASE_PAGES;
EraseInit.PageAddress = sector_addr;
EraseInit.NbPages = 1;

uint32_t SectorError = 0;
HAL_FLASHEx_Erase(&EraseInit, &SectorError);
FLASH_WaitForLastOperation(HAL_MAX_DELAY);
LOG_ASSERT(SectorError == 0xFFFFFFFF);

return sector_is_blank(sector_addr);
}

/**
* @brief Write bytes to flash
*
* @param buf
* @param addr
* @param len
* @return uint8
*/
static uint8 flash_write(const uint8_t* buf, uint32 addr, uint32 len) {
uint8_t ret = E_OK;

LOG(DEBUG, "Write flash at address %08lX\r\n", addr);
for (int i = 0; i < len; i += 4) {
uint32_t data = *((uint32_t*) ((void*) (buf + i)));

if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, addr + i, (uint64_t) data) != HAL_OK) {
LOG(ERROR, "Failed to write flash at address %08lX\r\n", addr + i);
ret = E_NOT_OK;
break;
}

if (FLASH_WaitForLastOperation(HAL_MAX_DELAY) != HAL_OK) {
LOG(ERROR, "Waiting on last operation failed\r\n");
ret = E_NOT_OK;
break;
}
}

// verify contents
if (memcmp((void*) addr, buf, len) != 0) {
LOG(ERROR, "Failed to write\r\n");
ret = E_NOT_OK;
}

return ret;
}

void board_flash_read(void* buffer, uint32 addr, uint32 len) {
memcpy(buffer, (void*) addr, len);
}

uint8 board_flash_write(void const* data, uint32 addr, uint32 len) {
// TODO skip matching contents
uint8 ret = E_OK;
if ((addr < BOARD_FLASH_APP_START) || (addr >= (BOARD_FLASH_APP_START + BOARD_FLASH_APP_SIZE))) {
if ((addr >= BOARD_FLASH_DATA_START) && (addr < (BOARD_FLASH_DATA_START + BOARD_FLASH_DATA_SIZE))) {
// TODO: addr is not sector aligned and len is not BOARD_FLASH_PAGE_SIZE
HAL_FLASH_Unlock();
flash_erase_sector(addr);
ret = flash_write(data, addr, len);
HAL_FLASH_Lock();
} else {
ret = E_NOT_OK;
}
} else {
if (!app_erased) {
board_flash_erase_app();
app_erased = TRUE;
}
HAL_FLASH_Unlock();
ret = flash_write(data, addr, len);
HAL_FLASH_Lock();
}

return ret;
}

uint8 board_flash_erase_app(void) {
// TODO implement later
uint8 ret = E_OK;
const uint32_t sector_count = BOARD_FLASH_APP_SIZE / BOARD_FLASH_PAGE_SIZE;
HAL_FLASH_Unlock();
for (int i = 0; i < sector_count; i++) {
if (flash_erase_sector(BOARD_FLASH_APP_START + i * BOARD_FLASH_PAGE_SIZE) != E_OK) {
ret = E_NOT_OK;
break;
}
}
HAL_FLASH_Lock();
return ret;
}

3.2、用.uf2的读写接口替换掉原先MSC的读写函数

在USB实现MSC功能中,最主要的两个接口就是:

  • tud_msc_read10_cb
  • tud_msc_write10_cb

我们直接将回调连接到uf2的读写接口即可,实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int32_t tud_msc_read10_cb(uint8_t lun, uint32_t lba, uint32_t offset, void* buffer, uint32_t bufsize) {
(void) lun;
// out of ramdisk
if ( lba >= DISK_BLOCK_NUM ) return -1;

uf2_read_block(lba, buffer);

return (int32_t) bufsize;
}

int32_t tud_msc_write10_cb(uint8_t lun, uint32_t lba, uint32_t offset, uint8_t* buffer, uint32_t bufsize) {
(void) lun;
// out of ramdisk
if ( lba >= DISK_BLOCK_NUM ) return -1;

uf2_write_block(lba, buffer);

return (int32_t) bufsize;
}

3.2、实现.uf2文件的读写解析接口

简单来说,uf2的读写接口既需要实现文件系统的格式解析,也需要实现uf2文件的格式解析;

  • fat文件系统的解析,我们返回固定格式的数据以让主机端(PC)可以正确识别成U盘;
  • uf2文件的解析,我们需要分离出其中的程序数据然后调用

在写入接口(PC拖拽文件到U盘触发)的实现中,先判断块数据是否是uf2的格式,如果不是uf2格式(fat文件系统交互数据),可以直接忽略;如果数据块为uf2文件,执行程序更新操作,会调用片内flash的写入api;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
/**  
* Write an uf2 block wrapped by 512 sector. * @return number of bytes processed, only 3 following values * -1 : if not an uf2 block * 512 : write is successful (BPB_SECTOR_SIZE == 512) * 0 : is busy with flashing, tinyusb stack will call write_block again with the same parameters later on */int uf2_write_block (uint32_t block_index, uint8_t *data) {
(void) block_index;
UF2_Block *bl = (void*) data;

if ( !is_uf2_block(bl) ) return -1;

if (bl->familyID == 0x5ee21072) {
// generic family ID
LOG(INFO, "get uf2 file\n");
// TODO: check first get address is app start
board_flash_write(bl->data, bl->targetAddr, bl->payloadSize);
}else {
// TODO family matches VID/PID
return -1;
}

WriteState* state = &_write_state;

/*------------- Update written blocks -------------*/
if ( bl->numBlocks ) {
// Update state num blocks if needed
if ( state->numBlocks != bl->numBlocks ) {
if ( bl->numBlocks >= MAX_BLOCKS || state->numBlocks ) {
state->numBlocks = 0xffffffff;
}
else {
state->numBlocks = bl->numBlocks;
}
}

if ( bl->blockNo < MAX_BLOCKS ) {
uint8_t const mask = 1 << (bl->blockNo % 8);
uint32_t const pos = bl->blockNo / 8;

// only increase written number with new write (possibly prevent overwriting from OS)
if ( !(state->writtenMask[pos] & mask) ) {
state->writtenMask[pos] |= mask;
state->numWritten++;
}

// flush last blocks
// TODO numWritten can be smaller than numBlocks if return early
if ( state->numWritten >= state->numBlocks ) {
board_flash_flush();
}
}
}

return BPB_SECTOR_SIZE;
}

在读出接口(PC读取U盘以及文件)的实现中,主要返回了文件系统的信息戳,以及三个文件(html、txt、uf2)的数据;uf2文件内容使用了片内flash的读接口;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
void uf2_read_block(uint32_t block_index, uint8_t* data) {  
memset(data, 0, FAT16_SECTOR_SIZE);
uint32_t sectionRelativeSector = block_index;
if(block_index == 0) {
// Request was for the Boot block
memcpy(data, &BootBlock, sizeof(BootBlock));
data[510] = 0x55; // Always at offsets 510/511, even when BPB_SECTOR_SIZE is larger
data[511] = 0xaa; // Always at offsets 510/511, even when BPB_SECTOR_SIZE is larger
} else if(block_index < FS_START_ROOTDIR_SECTOR) {
// Request was for a FAT table sector
sectionRelativeSector -= FS_START_FAT0_SECTOR;
if ( sectionRelativeSector >= BPB_SECTORS_PER_FAT ) {
sectionRelativeSector -= BPB_SECTORS_PER_FAT;
}

uint16_t* data16 = (uint16_t*) (void*) data;
uint32_t sectorFirstCluster = sectionRelativeSector * FAT_ENTRIES_PER_SECTOR;
uint32_t firstUnusedCluster = info[0].cluster_end + 1;

// OPTIMIZATION:
// Because all files are contiguous, the FAT CHAIN entries // are all set to (cluster+1) to point to the next cluster. // All clusters past the last used cluster of the last file // are set to zero. // // EXCEPTIONS: // 1. Clusters 0 and 1 require special handling // 2. Final cluster of each file must be set to END_OF_CHAIN //
// Set default FAT values first. for (uint16_t i = 0; i < FAT_ENTRIES_PER_SECTOR; i++) {
uint32_t cluster = i + sectorFirstCluster;
if (cluster >= firstUnusedCluster) {
data16[i] = 0;
}
else {
data16[i] = cluster + 1;
}
}

// Exception #1: clusters 0 and 1 need special handling
if (sectionRelativeSector == 0) {
data[0] = BPB_MEDIA_DESCRIPTOR_BYTE;
data[1] = 0xff;
data16[1] = FAT_END_OF_CHAIN; // cluster 1 is reserved
}

// Exception #2: the final cluster of each file must be set to END_OF_CHAIN
for (uint32_t i = 0; i < 2; i++) {
uint32_t lastClusterOfFile = info[i].cluster_end;
if (lastClusterOfFile >= sectorFirstCluster) {
uint32_t idx = lastClusterOfFile - sectorFirstCluster;
if (idx < FAT_ENTRIES_PER_SECTOR) {
// that last cluster of the file is in this sector
data16[idx] = FAT_END_OF_CHAIN;
}
}
}
}
else if ( block_index < FS_START_CLUSTERS_SECTOR ) {
// Request was for a (root) directory sector .. root because not supporting subdirectories (yet)
sectionRelativeSector -= FS_START_ROOTDIR_SECTOR;

DirEntry *d = (void*) data; // pointer to next free DirEntry this sector
int remainingEntries = DIRENTRIES_PER_SECTOR; // remaining count of DirEntries this sector

uint32_t startingFileIndex;

if ( sectionRelativeSector == 0 ) {
// volume label is first directory entry
memcpy(d->name, (char const*) BootBlock.VolumeLabel, 11);
d->attrs = 0x28;
d++;
remainingEntries--;

startingFileIndex = 0;
}else {
// -1 to account for volume label in first sector
startingFileIndex = DIRENTRIES_PER_SECTOR * sectionRelativeSector - 1;
}

for ( uint32_t fileIndex = startingFileIndex;
remainingEntries > 0 && fileIndex < NUM_FILES; // while space remains in buffer and more files to add...
fileIndex++, d++ ) {
// WARNING -- code presumes all files take exactly one directory entry (no long file names!)
uint32_t const startCluster = info[fileIndex].cluster_start;

FileContent_t const *inf = &info[fileIndex];
memcpy(d->name, inf->name, 11);
d->createTimeFine = COMPILE_SECONDS_INT % 2 * 100;
d->createTime = COMPILE_DOS_TIME;
d->createDate = COMPILE_DOS_DATE;
d->lastAccessDate = COMPILE_DOS_DATE;
d->highStartCluster = startCluster >> 16;
d->updateTime = COMPILE_DOS_TIME;
d->updateDate = COMPILE_DOS_DATE;
d->startCluster = startCluster & 0xFFFF;
d->size = (inf->content ? inf->size : 0xffff);
}
}
else if ( block_index < BPB_TOTAL_SECTORS ) {
// Request was to read from the data area (files, unused space, ...)
sectionRelativeSector -= FS_START_CLUSTERS_SECTOR;

// plus 2 for first data cluster offset
uint32_t fid = info_index_of(2 + sectionRelativeSector);
FileContent_t const * inf = &info[fid];

uint32_t fileRelativeSector = sectionRelativeSector - (info[fid].cluster_start-2);

if ( fid != FID_UF2 ) {
// Handle all files other than CURRENT.UF2
size_t fileContentStartOffset = fileRelativeSector * BPB_SECTOR_SIZE;
size_t fileContentLength = inf->size;

// nothing to copy if already past the end of the file (only when >1 sector per cluster)
if (fileContentLength > fileContentStartOffset) {
// obviously, 2nd and later sectors should not copy data from the start
const void * dataStart = (inf->content) + fileContentStartOffset;
// limit number of bytes of data to be copied to remaining valid bytes
size_t bytesToCopy = fileContentLength - fileContentStartOffset;
// and further limit that to a single sector at a time
if (bytesToCopy > BPB_SECTOR_SIZE) {
bytesToCopy = BPB_SECTOR_SIZE;
}
memcpy(data, dataStart, bytesToCopy);
}
} else {
// CURRENT.UF2: generate data on-the-fly
uint32_t addr = BOARD_FLASH_APP_START + (fileRelativeSector * UF2_FIRMWARE_BYTES_PER_SECTOR);
if ( addr < (BOARD_FLASH_ADDR_BASE + _flash_size) ) {
UF2_Block *bl = (void*) data;
bl->magicStart0 = UF2_MAGIC_START0;
bl->magicStart1 = UF2_MAGIC_START1;
bl->magicEnd = UF2_MAGIC_END;
bl->blockNo = fileRelativeSector;
bl->numBlocks = UF2_SECTOR_COUNT;
bl->targetAddr = addr;
bl->payloadSize = UF2_FIRMWARE_BYTES_PER_SECTOR;
bl->flags = UF2_FLAG_FAMILYID;
bl->familyID = BOARD_UF2_FAMILY_ID;

board_flash_read(bl->data, addr, bl->payloadSize);

}
// memset(data, 0, 512);
}
}
}

3.3 Bootloader跳转App逻辑

在bootloader的程序中进行判断,如果App程序的一致性和完整性如果没问题的话,并且不需要保持在当前升级模式(Bootloader),立即跳转App程序并运行;否则,保持在Bootloader下,等待程序更新;

1
2
3
4
5
6
7
8
9
if (board_app_valid() == TRUE) {  
if (board_is_bootloader_keep() == TRUE) {
board_flash_write("APPVALID\r\nXXXXXX\r\n", BOARD_FLASH_DATA_START, 18u);
} else {
board_app_jump();
// can't hit here
while (1) {}
}
}

4、App的程序实现

相比较于Bootloader,App需要做的工作量就比较少了:

  • 1、程序链接地址(程序起始地址)指定偏移量;
  • 2、如果需要切换中断向量表,需要重定向中断向量表;
  • 3、App跳转Bootloader的逻辑

4.1 程序链接起始地址和中断向量表更改

以STM32F1为例,在链接.ld文件中,修改程序大小和偏移地址:

1
2
3
4
MEMORY {
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K
FLASH (rx) : ORIGIN = 0x800a000, LENGTH = 80K
}

App的中断向量表地址重新指定:

1
2
#define VECT_TAB_BASE_ADDRESS   FLASH_BASE 
#define VECT_TAB_OFFSET 0x0000a000U

4.2 App跳转Bootloader逻辑实现

当接收到"UP\r\n"命令时,更新公共区标志并复位

1
2
3
4
5
6
7
8
9
10
11
12
13
case 'u':
case 'U': {
if(cmd_len >= 2) {
if((line[1] == 'P' || line[1] == 'p') ) {
board_flash_write("APPVALID\r\nUPDATE\r\n", BOARD_FLASH_DATA_START, 18u);
board_dfu_complete();
} else {
ret = COMMANDER_ERROR;
}
} else {
ret = COMMANDER_ERROR;
}
} break;

5.更多

PicoLIN小工具上就使用了这种升级方式,看演示:

有关Bootloader和App的设计远不止于此,正如前面提到的程序一致性和完整性的检查,这块深入下去可以往数据安全、加密解密展开细化;另外,关于App,还有更多的机制来完善整个应用,例如双分区回滚,升级日志等更加有意义的内容。