1. 项目概述
在嵌入式设备开发中,固件更新是一个绕不开的话题。想象一下,当你的设备已经部署在现场,却发现需要修复一个关键bug或者添加新功能时,如果每次都要拆机用JTAG烧录,那简直是工程师的噩梦。这就是为什么IAP(In-Application Programming)技术如此重要 - 它允许设备在不借助外部编程器的情况下,通过通信接口自行更新程序。
我最近完成了一个基于STM32F103的BootLoader IAP项目,配合C#开发的上位机工具,实现了稳定可靠的一键固件更新功能。这套方案已经在多个工业现场稳定运行,今天就来详细分享一下实现细节。
2. 系统架构设计
2.1 整体框架
系统由三大部分组成:
- BootLoader程序:驻留在MCU Flash起始位置,负责系统初始化和固件更新
- 应用程序:用户实际功能代码,存储在Flash的后续区域
- 上位机工具:用C#开发的PC端程序,负责固件文件传输
2.2 关键设计决策
为什么选择YModem协议而不是XModem或ZModem?
- YModem支持批处理文件传输
- 具备1024字节数据块大小,传输效率更高
- 内置CRC-16校验,比XModem的校验和更可靠
- 协议实现相对简单,适合资源有限的嵌入式系统
为什么选用UART/485而不是USB或以太网?
- UART在工业现场更普遍,兼容性更好
- 485总线支持长距离传输(可达1200米)
- 硬件成本低,几乎所有MCU都内置UART
- 协议栈简单,不依赖复杂驱动
3. BootLoader实现细节
3.1 内存空间规划
STM32F103C8T6的Flash大小为64KB,我们做如下划分:
- 0x08000000-0x08001FFF:BootLoader区(8KB)
- 0x08002000-0x0800FFFF:应用程序区(56KB)
- 0x08010000-0x0801FFFF:参数存储区(可选)
这种划分确保:
- BootLoader有足够空间实现基本功能
- 应用程序区可以容纳中等复杂度的固件
- 留有空间存储升级参数和备份
3.2 关键功能实现
3.2.1 启动流程
c复制void BootLoader_Init(void)
{
SystemClock_Config(); // 配置系统时钟
USART_Init(115200); // 初始化串口
LED_Init(); // 初始化状态指示灯
Key_Init(); // 初始化按键
// 检查升级标志
if(Check_Update_Flag())
{
Start_Update_Process();
}
else
{
Jump_To_App();
}
}
3.2.2 Flash操作关键点
Flash写入必须注意:
- 必须先解锁Flash
- 每次写入必须是半字(16bit)或字(32bit)
- 写入前必须擦除对应扇区
- 操作期间不能有中断干扰
改进后的Flash写入函数:
c复制#define FLASH_PAGE_SIZE 1024 // STM32F103页大小为1KB
uint8_t Flash_Write(uint32_t addr, uint8_t *data, uint32_t len)
{
FLASH_Unlock();
FLASH_ClearFlag(FLASH_FLAG_BSY | FLASH_FLAG_EOP | FLASH_FLAG_PGERR | FLASH_FLAG_WRPRTERR);
uint32_t page_addr = addr & ~(FLASH_PAGE_SIZE-1);
if(page_addr != (addr + len -1) & ~(FLASH_PAGE_SIZE-1))
{
// 跨页写入需要特殊处理
return 0;
}
// 擦除目标页
if(FLASH_ErasePage(page_addr) != FLASH_COMPLETE)
{
FLASH_Lock();
return 0;
}
// 写入数据
for(uint32_t i=0; i<len; i+=2)
{
uint16_t half_word = *(uint16_t*)(data + i);
if(FLASH_ProgramHalfWord(addr + i, half_word) != FLASH_COMPLETE)
{
FLASH_Lock();
return 0;
}
}
FLASH_Lock();
return 1;
}
4. YModem协议实现
4.1 协议帧格式
YModem使用三种类型的数据包:
- 起始包:包含文件名和文件大小
- 数据包:包含1024字节数据
- 结束包:表示传输结束
每个数据包结构:
code复制+------+------+------+------------+------------+
| 类型 | 序号 | ~序号 | 数据 | CRC16 |
+------+------+------+------------+------------+
1B 1B 1B 1-1024B 2B
4.2 协议状态机
c复制typedef enum {
YMODEM_STATE_IDLE,
YMODEM_STATE_WAIT_C,
YMODEM_STATE_RECEIVE_HEADER,
YMODEM_STATE_RECEIVE_DATA,
YMODEM_STATE_WRITE_FLASH,
YMODEM_STATE_COMPLETE,
YMODEM_STATE_ERROR
} YModem_State;
void YModem_Handler(uint8_t byte)
{
static YModem_State state = YMODEM_STATE_IDLE;
static uint8_t packet[1024 + 4]; // 数据缓冲区
static uint16_t index = 0;
static uint8_t seq = 0;
switch(state)
{
case YMODEM_STATE_IDLE:
if(byte == 'C') // 接收到起始字符
{
state = YMODEM_STATE_WAIT_C;
Send_ACK();
}
break;
case YMODEM_STATE_WAIT_C:
// 处理文件名包
if(byte == 0x01) // SOH
{
index = 0;
packet[index++] = byte;
state = YMODEM_STATE_RECEIVE_HEADER;
}
break;
// 其他状态处理...
}
}
5. 上位机开发关键点
5.1 串口通信实现
使用C#的SerialPort类时要注意:
- 设置合适的缓冲区大小
- 处理跨线程UI更新
- 实现超时重传机制
改进后的串口处理类:
csharp复制public class YModemSerialPort : IDisposable
{
private SerialPort _serialPort;
private CancellationTokenSource _cts;
public YModemSerialPort(string portName, int baudRate)
{
_serialPort = new SerialPort(portName, baudRate)
{
ReadTimeout = 1000,
WriteTimeout = 1000,
ReceivedBytesThreshold = 1
};
_serialPort.DataReceived += DataReceivedHandler;
}
private void DataReceivedHandler(object sender, SerialDataReceivedEventArgs e)
{
byte[] buffer = new byte[_serialPort.BytesToRead];
_serialPort.Read(buffer, 0, buffer.Length);
// 处理接收数据
ProcessReceivedData(buffer);
}
public async Task SendFileAsync(string filePath, IProgress<int> progress)
{
_cts = new CancellationTokenSource();
try
{
using (var stream = File.OpenRead(filePath))
{
// YModem协议发送流程
await SendYModemFile(stream, progress, _cts.Token);
}
}
catch (OperationCanceledException)
{
// 处理取消
}
finally
{
_cts.Dispose();
_cts = null;
}
}
public void Dispose()
{
_serialPort?.Dispose();
_cts?.Dispose();
}
}
5.2 进度显示优化
使用BackgroundWorker实现非阻塞UI更新:
csharp复制private void btnStart_Click(object sender, EventArgs e)
{
if(_worker == null)
{
_worker = new BackgroundWorker
{
WorkerReportsProgress = true,
WorkerSupportsCancellation = true
};
_worker.DoWork += (s, args) =>
{
using(var ymodem = new YModemSerialPort(cmbPort.Text, 115200))
{
var progress = new Progress<int>(percent =>
{
_worker.ReportProgress(percent);
});
ymodem.SendFileAsync(txtFilePath.Text, progress).Wait();
}
};
_worker.ProgressChanged += (s, args) =>
{
progressBar.Value = args.ProgressPercentage;
lblStatus.Text = $"{args.ProgressPercentage}%";
};
_worker.RunWorkerCompleted += (s, args) =>
{
if(args.Error != null)
{
MessageBox.Show($"传输失败: {args.Error.Message}");
}
_worker = null;
};
}
_worker.RunWorkerAsync();
}
6. 实际应用中的经验分享
6.1 常见问题排查
-
传输中途失败:
- 检查波特率是否匹配
- 降低波特率测试(特别是在长距离485传输时)
- 增加协议超时时间
-
写入Flash失败:
- 确保目标地址已擦除
- 检查供电是否稳定(Flash写入需要较高电压)
- 避免在写入过程中产生中断
-
跳转到应用程序失败:
- 检查应用程序的向量表偏移设置
- 确认堆栈指针初始值有效
- 检查应用程序的时钟配置是否与BootLoader冲突
6.2 性能优化技巧
-
Flash写入加速:
- 使用双缓冲技术:当写入一个缓冲区时,准备下一个缓冲区
- 批量擦除:提前擦除所有需要的扇区
-
协议优化:
- 实现滑动窗口协议,允许连续发送多个包再等待ACK
- 动态调整包大小:根据信号质量在128B和1024B之间切换
-
内存优化:
- 使用内存池管理动态内存
- 将常量字符串放入Flash而非RAM
7. 扩展功能实现
7.1 固件加密与校验
为确保固件安全,可以添加:
- AES加密传输
- SHA-256校验和验证
- 数字签名验证
加密传输实现示例:
c复制void Decrypt_Firmware(uint32_t addr, uint32_t size)
{
AES128_Init(&aes, secret_key);
for(uint32_t i=0; i<size; i+=16)
{
uint8_t block[16];
Flash_Read(addr+i, block, 16);
AES128_Decrypt(&aes, block);
Flash_Write(addr+i, block, 16);
}
}
7.2 断点续传功能
在BootLoader中添加状态保存:
c复制typedef struct {
uint32_t file_size;
uint32_t received_size;
uint8_t file_name[32];
uint16_t last_packet_crc;
uint8_t last_packet_seq;
} Update_Context;
void Save_Update_Context(Update_Context *ctx)
{
uint32_t crc = CRC32_Calculate((uint8_t*)ctx, sizeof(Update_Context));
Flash_Write(PARAM_BASE, (uint8_t*)ctx, sizeof(Update_Context));
Flash_Write(PARAM_BASE + sizeof(Update_Context), (uint8_t*)&crc, 4);
}
8. 移植到其他平台
8.1 移植到STM32H7系列
主要修改点:
- Flash操作接口不同
- 时钟配置差异
- 向量表偏移量调整
H7系列的Flash写入示例:
c复制void Flash_Write_H7(uint32_t addr, uint8_t *data, uint32_t len)
{
HAL_FLASH_Unlock();
FLASH_EraseInitTypeDef erase = {
.TypeErase = FLASH_TYPEERASE_SECTORS,
.Banks = FLASH_BANK_1,
.Sector = FLASH_SECTOR_5,
.NbSectors = 1,
.VoltageRange = FLASH_VOLTAGE_RANGE_3
};
uint32_t sector_error;
HAL_FLASHEx_Erase(&erase, §or_error);
for(uint32_t i=0; i<len; i+=8)
{
uint64_t word = *(uint64_t*)(data + i);
HAL_FLASH_Program(FLASH_TYPEPROGRAM_DOUBLEWORD, addr + i, word);
}
HAL_FLASH_Lock();
}
8.2 移植到其他通信接口
例如改用CAN总线传输:
c复制void CAN_Send_YModem(uint8_t *data, uint16_t len)
{
CAN_TxHeaderTypeDef header = {
.StdId = 0x321,
.ExtId = 0,
.IDE = CAN_ID_STD,
.RTR = CAN_RTR_DATA,
.DLC = len > 8 ? 8 : len
};
HAL_CAN_AddTxMessage(&hcan, &header, data, &tx_mailbox);
if(len > 8)
{
CAN_Send_YModem(data + 8, len - 8);
}
}
9. 测试与验证方法
9.1 自动化测试框架
构建一个测试框架验证BootLoader:
- 模拟各种异常情况(断电、数据错误等)
- 性能测试(传输速度、稳定性)
- 边界测试(最大文件大小、最小文件大小)
Python测试脚本示例:
python复制import serial
import time
import os
def test_bootloader_update(port, baudrate, file_path):
ser = serial.Serial(port, baudrate, timeout=1)
# 1. 发送启动字符
ser.write(b'C')
time.sleep(0.1)
# 2. 检查响应
if ser.read(1) != b'C':
raise Exception("握手失败")
# 3. 发送测试文件
with open(file_path, 'rb') as f:
data = f.read()
# 简化的YModem发送流程
ser.write(b'\x01\x00\xff') # SOH + 序号 + ~序号
ser.write(data[:128])
ser.write(b'\x00\x00') # CRC16占位
# ...完整协议实现
# 检查最终结果
response = ser.read(100)
if b'Success' not in response:
raise Exception("更新失败")
ser.close()
9.2 现场问题诊断
当现场设备升级失败时,可以通过以下步骤诊断:
- 收集日志信息
- 复现问题(使用相同环境和文件)
- 分析通信数据(用逻辑分析仪抓取串口数据)
- 检查Flash内容(通过读取Flash验证写入是否正确)
10. 工程管理建议
10.1 版本兼容性处理
- 在固件头中添加版本信息:
c复制typedef struct {
uint32_t magic; // 固定标识 0xAA55CC33
uint16_t hw_version; // 硬件版本
uint16_t fw_version; // 固件版本
uint32_t crc32; // 固件校验和
uint32_t length; // 固件长度
} Firmware_Header;
- BootLoader中实现版本检查:
c复制int Check_Version_Compatibility(Firmware_Header *hdr)
{
if(hdr->magic != 0xAA55CC33) return 0;
if(hdr->hw_version < MIN_HW_VERSION) return 0;
if(Calculate_CRC(APP_BASE, hdr->length) != hdr->crc32) return 0;
return 1;
}
10.2 固件打包脚本
使用Python脚本自动化固件打包:
python复制import struct
import zlib
import os
def pack_firmware(input_bin, output_bin, hw_ver, fw_ver):
with open(input_bin, 'rb') as f:
data = f.read()
header = struct.pack('<LHHLL',
0xAA55CC33, # magic
hw_ver, # hardware version
fw_ver, # firmware version
zlib.crc32(data),# crc32
len(data) # length
)
with open(output_bin, 'wb') as f:
f.write(header)
f.write(data)
这套BootLoader方案在实际项目中表现出色,特别是在工业现场环境下,通过485总线可以实现百米距离的可靠固件更新。最难能可贵的是,它的核心代码只有不到10KB,却实现了如此强大的功能。