1. 项目概述与核心价值
在嵌入式产品开发与维护的生命周期中,固件升级是一个绕不开的环节。想象一下,产品已经部署到成千上万的现场,这时发现了一个需要修复的Bug,或者需要增加一个新功能。如果每个设备都需要拆机、取下芯片、用专用编程器烧录,那成本和时间将是灾难性的。这正是我们今天要深入探讨的MC68HC912B32 Flash EEPROM串行引导加载器所要解决的核心痛点。
我手头这份来自Freescale(现NXP)的AN1718应用笔记,虽然发布于1997年,但其设计思想至今仍极具参考价值。它围绕MC68HC912B32这颗经典的16位微控制器,构建了一个通过串口(SCI)就能完成固件更新的完整方案。这个方案的精妙之处在于,它充分利用了芯片自身的硬件特性——2KB的擦除保护引导块(Bootblock),将引导程序固化其中,从而实现了对主程序区(30KB)的“自举”编程。这意味着,你只需要一根串口线,就能让设备自己更新自己的“大脑”,无需任何额外的昂贵编程工具。
对于嵌入式开发者而言,理解并实现这样一个引导加载器,不仅仅是完成一个功能,更是掌握了产品后期维护和迭代的主动权。它降低了现场支持的成本,延长了产品的有效生命周期,甚至能为产品增加“远程升级”的潜在能力(结合无线模块)。接下来,我将结合这份文档和我的实际工程经验,为你拆解这个引导加载器从设计思路到代码实现的每一个细节,并分享那些在数据手册里找不到的“踩坑”心得。
2. MC68HC912B32 Flash EEPROM特性深度解析
在动手写代码之前,我们必须吃透硬件。MC68HC912B32的Flash模块是其实现引导加载器的物理基础,它的几个关键特性直接决定了软件算法的设计。
2.1 存储结构与访问特性
这颗芯片的Flash被组织为16K x 16位(即32KB)的阵列。在软件视角下,它可以按字节或按字(16位)访问。这里有一个极易被忽略但至关重要的细节:编程操作(写入)只能针对字节或对齐的字进行。所谓“对齐的字”,指的是地址为偶数的字(例如$8000, $8002)。如果你试图向一个奇地址(如$8001)写入一个字,硬件只会编程其高字节(即地址$8001处的字节),低字节($8002)的操作会被忽略。在编写将二进制数据写入Flash的代码时,必须对数据地址进行对齐检查,否则会导致数据错位,程序跑飞。
2.2 编程与擦除的电压要求
与许多现代微控制器内部使用电荷泵产生编程电压不同,MC68HC912B32的Flash编程和擦除需要一个外部施加的电压V_FP。文档强调,V_FP必须始终大于等于V_DD - 0.5V,否则会损坏Flash阵列。这是一个硬性的安全限制。
实操心得:
V_FP电源设计在实际电路中,V_FP通常由一个独立的LDO或可调电源芯片提供。我的经验是:
- 上电时序是关键:必须确保在
V_DD(芯片主电源)稳定后,再使能V_FP。最好用一颗GPIO控制V_FP电源芯片的使能脚,在需要擦写时才打开。- 电压精度与纹波:
V_FP的电压值必须严格参照数据手册的电气特性章节(例如可能是12V±5%)。电压偏低会导致擦写失败,偏高则会永久损伤芯片。电源的纹波要小,尤其在编程脉冲期间,电压稳定是成功率的保障。- 泄放通路:关闭
V_FP后,其引脚上的电荷需要快速泄放,以免产生残留电压。可以在V_FP引脚到地之间接一个稍大阻值的电阻(如100kΩ)。
2.3 引导块(Bootblock)的保护机制
这是整个引导加载器设计的基石。MC68HC912B32将最高的2KB地址空间($F800—$FFFF)划为引导块,这个区域可以被配置为擦除保护。一旦保护生效,常规的擦除命令无法清除这个区域的内容。我们的引导加载器程序就永久地“烧死”在这里。即使主程序区在升级过程中被意外擦除或写入错误数据,这个2KB的“火种”依然存在,确保系统至少能回到引导加载模式,给了我们“重刷”的机会。
然而,这也带来了一个挑战:复位和中断向量表也位于这个区域的顶部($FFC0—$FFFF)。如果我们更新了主程序,并希望使用新的中断服务程序,这些向量是无法直接修改的。文档中提出的“二级中断向量表”方案,是解决此问题的经典思路,我们会在后续章节详细剖析。
3. 引导加载器的整体架构与设计思路
一个健壮的引导加载器,需要在功能、安全性和对主程序影响之间取得平衡。AN1718给出的方案是一个很好的范本。
3.1 硬件需求最小化
方案的精髓在于极简的硬件依赖:
- 片上SCI:用于通信,无需额外通信芯片。
- 一个GPIO引脚(PDLC0):作为启动模式选择引脚。上电或复位时,检测该引脚电平。拉低则进入引导加载模式,拉高则跳转到用户应用程序。这个引脚通常通过一个跳线帽或测试点连接到地。
- 可选的RS-232电平转换器:如果主机是PC,则需要MAX232这类芯片进行电平转换。文档提到一个很经济的思路:可以将这部分电路做在一个小适配板上,仅在生产或维修时由工程师接上,从而不增加每个产品的BOM成本。
3.2 软件流程总览
引导加载器上电后的决策流程,可以用以下伪代码清晰表述:
void BootStart(void) { if (PDLC0 pin == LOW) { // 进入引导加载模式 CopyBootloaderCodeFromFlashToRAM(); JumpToBootloaderInRAM(); } else { // 跳转到用户应用程序 UserAppResetVector = *(uint16_t*)SECONDARY_RESET_VECTOR_ADDR; JumpTo(UserAppResetVector); } }这里有一个关键操作:将引导加载器代码从Flash引导块复制到RAM中运行。为什么?因为Flash在擦写自身(非引导块区域)时,会产生内部高压,可能导致读取操作不稳定甚至失败。如果CPU正在从Flash取指执行擦写Flash的代码,极有可能发生取指错误,导致程序跑飞。将关键的控制代码搬到RAM中执行,就规避了这个问题。
3.3 二级中断向量表与跳转表
这是解决引导块向量不可改的核心方案。我们在引导块下方的某个固定地址(例如文档中的$F7C0—$F7FF)开辟一块区域,作为“二级向量表”。这个表存储的是用户应用程序中各个中断服务程序的实际入口地址。
然后,在引导块内,我们建立一个“跳转表”。当中断发生时,CPU依然会去引导块中的原始向量表取地址,但这个地址指向的是我们跳转表中的一条JMP指令。这条指令再通过PC相对间接寻址的方式,跳转到二级向量表中对应的地址,最终执行用户程序的中断服务例程。
以IRQ中断为例:
- 硬件中断发生。
- CPU从
$FFF2-$FFF3(原始IRQ向量)取出地址,假设这个地址是JumpTable_IRQ(位于引导块内)。 - 执行
JumpTable_IRQ处的指令:JMP [SECONDARY_IRQ_VECTOR, PCR]。这是一条6个时钟周期的指令。 - CPU计算
SECONDARY_IRQ_VECTOR(例如$F7F2)相对于当前PC的偏移,并从该地址取出一个16位的目标地址。 - CPU跳转到这个目标地址,即用户应用程序的IRQ处理函数。
延迟分析:文档提到在8MHz系统时钟下,这增加了约750ns的中断延迟。对于绝大多数应用,这个开销是可接受的。它用极小的性能代价,换来了固件升级时中断向量可灵活配置的巨大便利。
4. S-Record协议与串行通信实现
引导加载器需要一种标准格式来接收来自主机的程序数据。Motorola S-Record(或称SREC)格式因其简单、直观且包含校验,成为嵌入式引导加载器的经典选择。
4.1 S-Record格式精讲
我们主要处理三种类型的S-Record:
- S0记录:文件头记录,通常包含文件名等信息。引导加载器应忽略此记录。
- S1记录:数据记录,包含要写入Flash的16位地址和数据。
- S9记录:文件结束记录,标识传输完成。
一条典型的S1记录格式如下:
S113800048656C6C6F20576F726C64210A2B我们来拆解它:
S1:记录类型。13:记录长度(字节数),表示后面的字节对(十六进制字符对)数量。这里是0x13=19个字节对,即38个字符。8000:4字符(2字节)的加载地址,表示数据应写入的起始地址0x8000。48656C6C6F20576F726C64210A:实际的程序/数据,这里是ASCII码“Hello World!\n”的十六进制表示。2B:校验和。计算规则是:0xFF - (记录中所有字节和 & 0xFF)。接收方将所有字节(包括长度、地址、数据)相加,再加上校验和,结果的低8位应为0xFF。
4.2 引导加载器通信协议设计
文档中的引导加载器采用了一个简单的“请求-响应”流控协议,以应对Flash编程时间的不确定性。
- 主机发起连接:用户通过终端软件(如Tera Term, PuTTY)以9600波特率(8N1)连接设备。
- 设备上电(PDLC0拉低):设备输出提示符:
(E)rase or (P)rogram:。 - 用户选择模式:
- 输入
E或e:执行擦除操作(需先确保V_FP已施加)。 - 输入
P或p:进入编程模式。
- 输入
- 编程模式下的数据流:
- 设备准备好接收数据后,发送一个“步调”字符
*(ASCII 0x2A)。 - 主机收到
*后,发送一条完整的S-Record(以回车换行结尾)。 - 设备接收、校验并编程该条记录。
- 编程完成后,设备再次发送
*,请求下一条记录。 - 如此循环,直到设备收到
S9记录,完成整个编程过程。
- 设备准备好接收数据后,发送一个“步调”字符
注意事项:终端软件配置很多新手在这里会卡住。你的终端软件必须支持“每次发送一行后等待特定字符”的功能。例如在Tera Term中,需要设置:
File->Transfer->XMODEM/...当然这里不是用XMODEM,而是要用Send file...对话框,并勾选类似“Pause after every line”或“Wait for prompt string”的选项,并将提示字符串设置为“*”。如果软件不支持此功能,数据会一股脑发送出去,导致引导加载器缓冲区溢出而崩溃。
4.3GetSRecord子程序实现细节
这个子程序是通信协议解析的核心。它的任务是从SCI接收字符流,识别出一条完整的、有效的S-Record。
关键实现步骤:
- 状态机搜索:程序首先处于“搜索起始符”状态,持续读字符,直到遇到
S。 - 类型识别:读取下一个字符,判断是
0、1还是9,并设置相应的记录类型标志。 - 长度解析:读取两个十六进制字符,转换为一个字节的长度值
Len。这个Len包含了地址(2字节)、数据(Len-3字节)和校验和(1字节)的总长度。 - 数据接收与校验:循环接收
(Len*2)个十六进制字符(因为每个字节用两个ASCII字符表示)。边接收边将其两两组合,转换成二进制数据,存入全局缓冲区。同时,累加所有原始字节(长度、地址、数据)的值。 - 校验和验证:接收完校验和字节后,将其也加入累加和。根据S-Record规范,所有字节(包括校验和)相加后的低8位结果应为
0xFF。代码中常用的一个等效判断是:(Sum & 0xFF) == 0xFF。如果校验失败,应丢弃该记录并报告错误。
一个常见的坑:文档末尾的警告非常重要——不要处理数据字段超过64字节的S-Record。这是因为引导加载器的接收缓冲区大小是有限的。现代的编译器/链接器在生成S-Record时,默认的行长度可能远超64字节。因此,在生成用于引导加载的S19文件时,必须指定最大行长度。例如,在GCC链接器脚本或Keil、IAR的输出配置中,需要将S-Record的宽度(Width)设置为16(代表16字节数据)或32。
5. Flash擦除与编程算法的代码级剖析
这是引导加载器最核心、也最需要精细操作的部分。任何时序或操作顺序的偏差都可能导致擦写失败或器件损坏。
5.1 擦除算法 (FErase子程序)
Flash的擦除是以整个阵列(除引导块外)为单位的“块擦除”。算法流程严格遵循数据手册的时序要求:
配置与准备:
- 设置Flash控制寄存器(
FEECTL),禁用引导块擦除(设置ENBD位?需查具体寄存器),使能地址/数据锁存。 - 施加
V_FP电压到V_FP引脚。 - 向Flash锁存器执行一个“哑写”操作,以启动擦除序列。这个写操作本身不关心数据,它是一个触发信号。
- 设置Flash控制寄存器(
擦除脉冲循环:
- 启动一个100ms的定时器(使用片上Timer模块的Output Compare功能)。
- 设置
FEECTL寄存器中的擦除使能位,施加擦除脉冲。 - 等待100ms定时结束。
- 清除擦除使能位,移除擦除脉冲。
- 延迟约1ms的恢复时间。
- 验证:遍历整个待擦除的Flash区域($8000 - $F7FF),读取每一个字节,检查其是否为
0xFF(已擦除状态)。 - 如果全部为
0xFF,则跳出循环,记录下所用脉冲次数NumPulses。 - 如果未全部擦除,且脉冲次数未超限(例如5次),则重复施加擦除脉冲。
- 如果超限仍未擦除,则报告“擦除失败”。
余量脉冲(Margin Pulses):
- 这是一个关键的安全加固步骤,但常被忽略。在验证擦除成功后,需要再施加
NumPulses次相同的擦除脉冲。 - 目的是提供100%的擦除余量,确保每个存储单元都深度擦除,提高数据保留时间和可靠性。
- 施加余量脉冲后,必须再次进行全阵列验证。如果验证失败,说明Flash阵列可能已损坏。
- 这是一个关键的安全加固步骤,但常被忽略。在验证擦除成功后,需要再施加
实操心得:擦除失败排查如果擦除总是失败,请按以下顺序检查:
V_FP电压:用示波器测量V_FP引脚在擦除脉冲期间的电压。确保其值在数据手册规定范围内(如12V±0.5V),并且上升沿干净,在使能位设置前就已稳定。- 时序:100ms的擦除脉冲时间和1ms的恢复时间必须用定时器精确保证,不能用软件空循环。芯片主频是否准确?定时器预分频设置是否正确?
- 操作序列:对
FEECTL寄存器的写操作序列必须严格按数据手册进行。通常需要先写一个特定的值到某个地址(解锁序列),再设置擦除位。务必核对最新版数据手册的示例代码。
5.2 编程算法 (ProgFBlock子程序)
编程是以字节或对齐字为单位进行的。文档中的实现为了代码简洁,选择了逐字节编程。虽然理论上逐字编程能快一倍,但正如文档分析,在串口通信成为主要瓶颈的背景下,节省的几秒编程时间意义不大。
编程脉冲时序的精准控制是难点。文档要求编程脉冲宽度t_{PGS}典型值为20-25µs,恢复时间t_{RCV}最小为10µs。为了实现高精度延时,代码采用了先停止定时器再操作的技巧:
; 假设需要22µs延时,系统时钟为8MHz,定时器预分频为1(每周期0.125µs) ; 22µs / 0.125µs = 176个计数周期 BCLR TSCR, #$80 ; 清除TEN位,禁用定时器,TCNT停止计数但保持当前值 LDD TCNT ; 读取当前定时器计数 ADDD #176 ; 加上176个计数周期 STD TC0 ; 设置输出比较寄存器TC0 BSET FEECTL, #ENPE ; 施加编程电压(设置ENPE位) BSET TSCR, #$80 ; 设置TEN位,使能定时器 ... ; 等待中断或轮询标志位禁用定时器后,TCNT值冻结。此时计算目标时间点并写入比较寄存器,再使能定时器,可以消除计算和写寄存器过程中的指令执行时间误差,实现非常精确的延时。
编程流程如下:
- 初始化:设置
ProgPulses=0,PMarginFlag=0。 - 单字节编程循环:
- 配置Flash为编程模式,写入目标地址和数据到锁存器。
- 施加
V_FP,同时启动22µs精确延时。 - 延时结束后移除
V_FP。 - 等待约11µs恢复时间。
- 读取刚编程的地址,与目标数据比较。
- 如果相等,则跳出循环,记录
ProgPulses。 - 如果不相等,且
ProgPulses未超限(如50次),则ProgPulses++,重复编程脉冲。
- 余量脉冲:设置
PMarginFlag=1,再施加ProgPulses次编程脉冲(此阶段不进行验证比较)。 - 最终验证:清除
PMarginFlag,再次读取Flash数据并与目标数据比较。相等则成功,否则失败。
注意事项:编程地址范围引导加载器必须检查S-Record中的加载地址。只能对
$8000至$F7FF(引导块以下)的地址进行编程。任何试图对引导块($F800-$FFFF)或非法地址的编程操作,都应被拒绝并报错。虽然从硬件上可能无法写入受保护的引导块,但软件检查是必要的安全措施。
6. 工程实现中的关键支持例程与优化技巧
除了核心的擦写算法,一些支撑性的子程序同样影响系统的稳定性和易用性。
6.1 字符I/O与字符串输出 (getchar,putchar,OutStr)
getchar:应从SCI数据寄存器轮询或中断接收字符。在引导加载器中,通常采用轮询方式以简化代码。务必注意超时处理,防止因主机未发送数据而导致程序死等。putchar:轮询发送完成标志后,写入数据寄存器。OutStr:遍历一个以NULL结尾的字符串,逐个调用putchar输出。这是输出提示信息的基础。
6.2 十六进制转换 (GetHexByte,CvtHex,IsHex)
这是解析S-Record的必备工具。
IsHex:判断一个ASCII字符是否为有效的十六进制字符(0-9,A-F,a-f)。CvtHex:将单个十六进制ASCII字符转换为4位二进制数。GetHexByte:连续调用两次getchar,得到两个字符,分别转换后组合成一个字节。必须加入校验,如果接收到的不是合法十六进制字符,应视为通信错误。
6.3 代码在RAM中的运行
如前所述,擦写Flash的代码必须从RAM执行。在汇编代码中,这通常通过“运行时复制”来实现。在链接器脚本中,我们需要定义两个段:
.bootloader_code:存放于Flash引导块中,包含所有代码和数据。.bootloader_ram:指定在RAM中的运行地址。
在启动代码BootStart中,需要将.bootloader_code段(或其中需要RAM运行的部分)复制到.bootloader_ram指定的RAM地址,然后跳转到RAM中的代码入口点开始执行。
一个易错点:复制代码时,必须复制.bootloader_code的整个映像,包括其中的只读数据(如字符串常量)。如果漏掉这些数据,在RAM中运行的代码引用这些常量时,会跑到错误的地址去。
7. 常见问题、调试技巧与实战经验
根据我多年调试嵌入式引导加载器的经验,以下是一些高频问题和解决思路:
问题1:上电后终端无任何输出,一片空白。
- 检查1:电源与复位。用示波器看
V_DD和复位引脚波形,确保上电稳定,复位信号正常释放。 - 检查2:波特率。确认MCU的系统时钟配置是否正确(晶振是否起振?PLL是否锁定?),计算出的SCI波特率与终端设置(9600 8N1)是否完全匹配。可以尝试发送一个字符后,用示波器测量SCI_TX引脚,计算实际波特率。
- 检查3:启动引脚。确认PDLC0引脚在上电复位时的电平。如果内部上拉较弱,悬空可能导致状态不确定,务必用电阻上拉或下拉到明确电平。
- 检查4:代码是否真的在运行?在启动代码最开始,让一个GPIO引脚翻转,用示波器看是否有脉冲,这是最直接的“心跳”检测。
问题2:可以收到提示符,但发送E擦除后,一直卡住或返回“Not Erased”。
- 检查1:
V_FP电压。这是首要怀疑对象。必须在擦除命令执行期间测量V_FP引脚电压,确保其达到规定值(如12V)且稳定。 - 检查2:
V_FP使能时序。确认代码中是在配置好Flash控制寄存器之后,才打开V_FP电源。关闭时,应先移除V_FP,再修改控制寄存器。 - 检查3:看门狗。如果系统开启了看门狗,在漫长的擦除(100ms * N)过程中必须及时喂狗,否则会导致复位。
- 检查4:中断干扰。在擦写Flash期间,必须禁止所有中断。因为中断服务程序可能位于正在被擦写的Flash区域,导致不可预料的后果。
问题3:编程过程中,收到几个*后通信停止,或提示“Not Programmed”。
- 检查1:S-Record格式。用文本编辑器打开你的.s19文件,检查是否有超长的行(数据超过64字节)。用工具(如
objcopy)重新生成,限制行长度:objcopy -I ihex -O srec --srec-len=32 input.hex output.s19 - 检查2:地址对齐。确认S-Record中的数据地址是否都在
$8000-$F7FF范围内,且字编程时地址是否为偶数。 - 检查3:校验和。在
GetSRecord函数中,加入详细的校验和错误打印,输出计算值和接收值,便于定位是哪条记录出错。 - 检查4:缓冲区溢出。确保全局数据缓冲区足够大,能容纳一条最大长度的S-Record数据(长度字节+地址+数据+校验和)。
问题4:编程成功,但跳转到用户程序后不运行。
- 检查1:二级向量表。用户程序的链接器脚本,必须将中断向量表定位到二级向量表地址(如
$F7C0),并且内容是正确的函数入口地址。用仿真器或读取内存确认。 - 检查2:用户程序初始化。用户程序自己的启动代码(初始化栈、RAM、变量等)必须正确执行。引导加载器只负责跳转,不负责用户环境的初始化。
- 检查3:时钟配置。引导加载器和用户程序对系统时钟(如PLL)的配置必须一致。如果引导加载器设置了某个时钟频率,用户程序又试图修改,可能导致故障。最好由引导加载器完成基本的时钟初始化,用户程序不再修改。
进阶技巧:增加可靠性设计
- 通信协议增强:基础的“
*”协议很脆弱。可以升级为类似XMODEM的协议,包含数据包编号、ACK/NAK、CRC校验等,大幅提高在嘈杂环境中传输的可靠性。 - 完整性校验:编程完成后,不要仅仅依赖S-Record的校验和。应该对整个已编程区域进行一次完整的读取校验(CRC32或求和),与原始二进制文件的计算结果比对,确保万无一失。
- 备份与回滚:如果Flash空间充足,可以实现A/B双备份系统。引导加载器根据某个标志位,决定引导至A区或B区。升级时,将新固件写入非活动区,验证通过后更新标志位。这样即使新固件有问题,也能回滚到旧版本。
- 安全认证:对于需要防止非法固件刷写的产品,可以在引导加载器中加入简单的认证机制,例如要求主机发送一个预共享的密钥,或者对固件进行数字签名验证(虽然对HC12这类资源有限的MCU实现较复杂)。
实现一个稳定可靠的引导加载器,是嵌入式开发者从“功能实现”到“产品化思维”迈进的重要一步。它要求你对硬件时序、存储特性、通信协议和系统架构有更深的理解。希望这份基于AN1718的深度剖析和我个人的实战经验,能为你点亮这条路。当你第一次通过一根串口线,让手中的设备成功完成自我更新时,那种成就感,就是嵌入式开发的乐趣所在。