起因

最近所使用的单片机全都是基于STC8系列单片机实现,由于项目需要,需要实现在线远程更新功能。

本文所使用的单片机是STC8A8K64S4A12。注意:只有eeprom写的是IAP的型号才支持。

先交代一个 STC8 的存储特点:程序 Flash 与 EEPROM 统一编址在同一个 64K 空间,IAP 命令两者都能操作,下文说的“写到 eeprom 的某个地址”指的都是这个统一空间里的地址。

关于此芯片的重要说明:

2022-2-24到2022-3-21期间生产的此型号 程序空间为63K, EEPROM 固定为1K, EEPROM大小无法修改 其它时间生产的此型号的程序空间和EEPROM参数都是正确的 即: 程序空间为63.5K, EEPROM默认为0.5K, EEPROM大小可修改

需求

  • 设备装在现场,不拆机、不接 ISP 工具,通过串口或网络接收新固件并自我更新
  • 更新过程即使中途断电,用来救芯片的“搬运代码”也不能被破坏

技术实现原理

为什么“自己更新自己”这么别扭(根因)

STC8 是增强型 8051 内核,取指执行始终直接走 Flash:PC 走到哪条指令,就从 Flash 哪个地址取。而 IAP 的擦除以 512 字节扇区为单位。于是有一个绕不开的矛盾:正在运行的那段代码所在的扇区一旦被擦掉,之后取指读到的就是乱码指令,直接跑飞。这就是“用来更新代码的代码,必须与被更新的代码放在不同位置”的底层原因。

对比 STM32:它自更新同样要做 bootloader,但 Cortex-M 有向量表重定位(VTOR),部分型号还有双 bank(一边跑 bank1 一边写 bank2),做法已经标准化;STC8 没有这些硬件支持,地址布局全靠自己规划。

Flash 布局:三块区域各管一段

  • 0x0000:正在运行的固件。8051 复位后固定从 0x0000 取指,中断向量表也在最前面,所以运行代码永远在 Flash 最前端;
  • 0x3000:新固件暂存区,串口/网络收到的代码先写到这里;
  • 0xf800:搬运工具(bootloader 核心),负责把 0x3000 的内容逐扇区擦写回 0x0000,完成后触发软件复位,芯片从 0x0000 重新取指,新固件生效。

为什么搬运工具用汇编写而不用 C:它要在“亲手擦掉 0x0000”的环境里跑,必须体积极小、位置固定;C 编译器加上运行库的代码布局不可控,几十字节的手写汇编最稳妥。文末的 iapprogram[] 数组就是这段汇编对应的机器码。

IAP 是怎么动作的(寄存器与触发序列)

IAP 操作本质是往一组 SFR 写好命令再“按门铃”:

  • IAP_CMD 写 1/2/3 分别是读/编程/扇区擦除;IAP_ADDRH/L 放目标地址;IAP_DATA 放读出/写入的字节;
  • IAP_CONTR 使能 IAP 并配等待时间,汇编里的 WT_12M EQU 83H 就是 12MHz 主频对应的等待参数——这个值必须与实际主频匹配,配快了读写不稳定;
  • “按门铃”的方式是向 IAP_TRIG 依次写 0x5A、0xA5,序列全对命令才执行,这是防止程序跑飞时误触发擦除的硬件保险;
  • 每个操作完成后都走一遍 IAP_IDLE:关使能、清命令、把地址指到非 IAP 区,同样是防误触发。

防变砖的安装逻辑(Init_iapprogram 的用意)

0xf800 的工具码是整个方案的命根子:应用区 0x0000 坏了还能靠它救回来,它坏了就只能拆机接 ISP。所以初始化代码上电先 IapRead 逐字节比对 0xf800 内容与数组:一致说明工具区完好,直接返回;不一致才擦掉重装。既保证工具区永远有效,又不会每次上电都白白消耗一个扇区的擦写寿命(Flash 擦写次数是有限的)。

更新调用

调用入口是一个指向 0xf800 的函数指针。我写的汇编代码定义了代码地址在0xf800位置,然后通过R7、R6、R5、R4四个变量分别传了需要更新的代码位置以及大小——正好对上 Keil C51 的传参约定:第一个 unsigned short 参数放 R6R7,第二个放 R4R5。

调用方法如下:

1
2
void (*run_iapprogram)(unsigned short iapaddr, unsigned short iapsize) = 0xf800;
run_iapprogram(0x3000, newprogram_size);

但是在执行这个代码之前,需要保证0xf800这个地址已经是工具代码了,可以用如下代码实现。

 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
char code iapprogram[] = {
    0xc2, 0xaf, 0x78, 0x00, 0x79, 0x00, 0xb8, 0x00, 0x0b, 0xe9, 0x20, 0xe0, 0x07, 0x88, 0x82, 0x89,
    0x83, 0x12, 0xf8, 0x82, 0xc3, 0xe8, 0x2f, 0xf5, 0x82, 0xe9, 0x3e, 0xf5, 0x83, 0x12, 0xf8, 0x50,
    0x88, 0x82, 0x89, 0x83, 0x12, 0xf8, 0x69, 0xc3, 0x74, 0x01, 0x28, 0xf8, 0x74, 0x00, 0x39, 0xf9,
    0xc3, 0xe8, 0x9d, 0x70, 0xd1, 0xc3, 0xe9, 0x9c, 0x70, 0xcc, 0x75, 0xc7, 0x60, 0x02, 0xf8, 0x3d,
    0x75, 0xc7, 0x00, 0x75, 0xc5, 0x00, 0x75, 0xc6, 0x00, 0x75, 0xc3, 0x80, 0x75, 0xc4, 0x00, 0x22,
    0x75, 0xc7, 0x83, 0x75, 0xc5, 0x01, 0x85, 0x82, 0xc4, 0x85, 0x83, 0xc3, 0x75, 0xc6, 0x5a, 0x75,
    0xc6, 0xa5, 0x00, 0xe5, 0xc2, 0x12, 0xf8, 0x40, 0x22, 0x75, 0xc7, 0x83, 0x75, 0xc5, 0x02, 0x85,
    0x82, 0xc4, 0x85, 0x83, 0xc3, 0xf5, 0xc2, 0x75, 0xc6, 0x5a, 0x75, 0xc6, 0xa5, 0x00, 0x12, 0xf8,
    0x40, 0x22, 0x75, 0xc7, 0x83, 0x75, 0xc5, 0x03, 0x85, 0x82, 0xc4, 0x85, 0x83, 0xc3, 0x75, 0xc6,
    0x5a, 0x75, 0xc6, 0xa5, 0x00, 0x12, 0xf8, 0x40, 0x22, 0x78, 0x7f, 0xe4, 0xf6, 0xd8, 0xfd, 0x75,
    0x81, 0x07, 0x02, 0x00, 0x00
};

void (*run_iapprogram)(unsigned short iapaddr, unsigned short iapsize) = 0xf800;

void Init_iapprogram() {
    unsigned short i;
    for (i=0;i<sizeof(iapprogram);i++) {
        if (IapRead(0xf800+i) != iapprogram[i]) {
            IapErase(0xf800);
            for (i=0;i<sizeof(iapprogram);i++) {
                IapProgram(0xf800+i, iapprogram[i]);
            }
            return;
        }
    }
}

IapRead、IapErase与IapProgram的具体实现,直接看官方文档即可。

至于数组iapprogram怎么来的,可以参考本文最后面的汇编代码。

下载配置注意

最后需要注意的是,ISP下载程序时,eeprom必须选择64k

这个选项选小了,烧录器对空间的划分就与本文的地址布局对不上,0xf800 的工具区放不进去。

汇编源码

下面分享下我写的stc8的iap更新工具代码(汇编)。几个关键位置与上面的原理一一对应:

  • 开头 CLR EA 关全部中断:搬移途中来一个中断,向量表正在 0x0000 被改写,中断服务程序的入口地址是随机数;
  • CJNE R0,#00H,NOERASEJB ACC.0,NOERASE:只在偏移量是 512 整数倍时才执行擦除——按扇区边界对齐着擦;
  • 全部搬完后 MOV IAP_CONTR,#60H 触发软件复位,LJMP $ 原地等待复位生效,重启后从 0x0000 取指执行新固件。
 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
IAP_DATA DATA 0C2H
IAP_ADDRH DATA 0C3H
IAP_ADDRL DATA 0C4H
IAP_CMD DATA 0C5H
IAP_TRIG DATA 0C6H
IAP_CONTR DATA 0C7H

WT_12M EQU 83H

;本程序R1R0为偏移指针,R6R7为新的code的地址,R4R5为新code的大小

ORG 0000H
LJMP MAIN
ORG 0f800H
MAIN:
    CLR EA ;关闭所有中断,以免中途发生中断影响升级
    MOV R0,#00H
    MOV R1,#00H
LOOP:
    CJNE R0,#00H,NOERASE
    MOV A,R1
    JB ACC.0,NOERASE
    MOV DPL,R0
    MOV DPH,R1
    LCALL IAP_ERASE
NOERASE: ;当R0不为0x00,同时R1.0不为0时,跳过erase操作
    CLR C
    MOV A,R0
    ADD A,R7
    MOV DPL,A
    MOV A,R1
    ADDC A,R6
    MOV DPH,A ;将R1R0+R6R7的值存入DPTR中
    LCALL IAP_READ
    MOV DPL,R0
    MOV DPH,R1
    LCALL IAP_PROGRAM
    CLR C
    MOV A,#01H
    ADD A,R0
    MOV R0,A
    MOV A,#00H
    ADDC A,R1
    MOV R1,A
    CLR C
    MOV A,R0
    SUBB A,R5
    JNZ LOOP
    CLR C
    MOV A,R1
    SUBB A,R4
    JNZ LOOP ;将R1R0与R4R5进行比较,不相等则继续循环
    MOV IAP_CONTR,#60H ;让单片机重启
    LJMP $
IAP_IDLE:
    MOV IAP_CONTR,#0 ;关闭 IAP 功能
    MOV IAP_CMD,#0 ;清除命令寄存器
    MOV IAP_TRIG,#0 ;清除触发寄存器
    MOV IAP_ADDRH,#80H ;将地址设置到非 IAP 区域
    MOV IAP_ADDRL,#0
    RET
IAP_READ:
    MOV IAP_CONTR,#WT_12M ;使能 IAP
    MOV IAP_CMD,#1 ;设置 IAP 读命令
    MOV IAP_ADDRL,DPL ;设置 IAP 低地址
    MOV IAP_ADDRH,DPH ;设置 IAP 高地址
    MOV IAP_TRIG,#5AH ;写触发命令(0x5a)
    MOV IAP_TRIG,#0A5H ;写触发命令(0xa5)
    NOP
    MOV A,IAP_DATA ;读取 IAP 数据
    LCALL IAP_IDLE ;关闭 IAP 功能
    RET
IAP_PROGRAM:
    MOV IAP_CONTR,#WT_12M ;使能 IAP
    MOV IAP_CMD,#2 ;设置 IAP 写命令
    MOV IAP_ADDRL,DPL ;设置 IAP 低地址
    MOV IAP_ADDRH,DPH ;设置 IAP 高地址
    MOV IAP_DATA,A ;写 IAP 数据
    MOV IAP_TRIG,#5AH ;写触发命令(0x5a)
    MOV IAP_TRIG,#0A5H ;写触发命令(0xa5)
    NOP
    LCALL IAP_IDLE ;关闭 IAP 功能
    RET
IAP_ERASE:
    MOV IAP_CONTR,#WT_12M ;使能 IAP
    MOV IAP_CMD,#3 ;设置 IAP 擦除命令
    MOV IAP_ADDRL,DPL ;设置 IAP 低地址
    MOV IAP_ADDRH,DPH ;设置 IAP 高地址
    MOV IAP_TRIG,#5AH ;写触发命令(0x5a)
    MOV IAP_TRIG,#0A5H ;写触发命令(0xa5)
    NOP
    LCALL IAP_IDLE ;关闭 IAP 功能
    RET
END

踩坑记录与注意事项

  • 升级窗口内断电 = 只能拆机救:0x0000 只写了一半就断电,下次上电会在坏固件里跑飞,而 STC8 没有“先跑 bootloader 再跳应用”的向量重映射。对策:新固件先做完整校验(CRC 或和校验)再触发搬移;搬移窗口要短(数据量固定,几十 KB 也就几百毫秒);要求更高的产品把 0xf800 扩成常驻 boot,上电先校验应用区标志位,坏了先等重传,永远不直接跳 0x0000;
  • CLR EA 不是可有可无:升级前必须关中断,原因见上文汇编说明;
  • WT_12M 必须与实际主频匹配:换了晶振或改了主频忘了改等待参数,IAP 读写会随机出错;
  • 触发序列 0x5A、0xA5 顺序不能反,反了命令不执行;
  • 新固件别越过暂存区:0x3000 到 0xf800 之间约 50K,R4R5 传的是实际字节数,固件超长会把工具区一起冲掉;
  • 先比对再擦写:Init_iapprogram 对工具区这么做,应用区同样适用,没变化的扇区不擦,省擦写寿命;
  • 批次问题:2022-2-24~2022-3-21 生产的芯片 EEPROM 固定 1K(见文首说明),做地址规划前先核对芯片生产日期与手册。