ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32固件保护:如何用选项字节禁掉SWD和ISP彻底锁死代码

STM32固件保护:如何用选项字节禁掉SWD和ISP彻底锁死代码

做嵌入式开发最怕什么?代码被抄、固件被扒、产品被复刻。很多开发者觉得写完程序、烧进芯片就万事大吉了,但实际上芯片出厂默认状态就是个“透明保险柜”:SWD调试口和ISP串口引导都开着,谁拿个调试器往板子上一戳,就能把Flash里的固件读出来、改掉、甚至整个擦掉重写。我经常被问到的一个问题就是:怎么禁止STM32通过SWD和ISP升级来保护自己的产品?这个问题背后的核心需求,是防止别人通过调试接口或者串口下载接口重新写程序、读代码。这里要用到的关键手段,就是操作STM32的选项字节(Option Bytes),而HAL库里的HAL_FLASH_Program一族函数正是干这个的——很多人以为它只能写应用代码,其实它还能改写选项字节,给芯片上锁。这篇内容我结合自己实际做产品的经验,把从原理到代码、从避坑到量产的操作一次性写清楚。

1. 问题本质:SWD和ISP为什么是“泄密入口”

1.1 SWD调试口:出厂自带的“全权限通道”

SWD(Serial Wire Debug)是ARM Cortex-M内核标配的调试接口,只需要SWDIO、SWCLK两根线,加上GND和复位,就能通过调试器连接到芯片内部。它能干什么?读Flash、读RAM、读寄存器、单步执行、打断点、改内存数据,甚至还能通过串口窗口偷看你的日志输出和全局变量。

STM32出厂时,读保护(RDP)默认是Level 0,也就是完全没有任何保护。这时候你手里的板子,对调试器来说就是一个完全开放的芯片。任何人只要拿到你的板子,用调试器连上SWD接口,就能把固件整体读出来,然后用IDA或者Ghidra逆向分析。更狠的是,他都不用完整还原你的代码,直接把自己写的固件烧进去,你的硬件就成了他的“新作品”。

我见过一个案例,某开发者做了一款数据采集模块,觉得自己代码写得还行,结果客户拿到样机后,找个人用调试器几天就把固件抄走了,然后自己量产。他后来跟我说:“我连Flash里存的参数表都被人家读出来了。”这就是SWD口不设防的代价。

1.2 ISP串口引导:BOOT引脚一拉,固件随便刷

ISP(In-System Programming)是STM32出厂自带的系统存储器引导程序,藏在芯片的ROM区,用户没法改写它。它通过UART串口接收数据,可以擦除和写入主Flash。启动它的条件很简单:把BOOT0引脚拉高(部分系列还要配合BOOT1),然后复位芯片,芯片就会进入系统存储器里的bootloader,等待串口指令。

这意味着什么?哪怕你完全不接调试器,别人只要打开板子找到BOOT0引脚,用镊子或者杜邦线把BOOT0接到高电平,再复位一次,芯片就进入ISP模式了。然后通过串口工具连接USART1或USART2(具体看型号),发送原厂定义的协议命令,就能读取芯片信息、擦除Flash、写入新固件。整个过程只需要一个USB转串口模块,成本十几块钱。

1.3 安全与便利的天平:你到底要防谁

讨论保护方案之前,先想清楚一个核心问题:你的产品要防什么人?不同场景的取舍完全不同。

  • 防同行抄板:需要防的是“能拿到实物、有调试器、会逆向”的人,读保护和接口封锁必须做到硬件级。
  • 防普通用户误刷:用户不会刻意破解,但可能因为好奇按住复位键、掰BOOT引脚,或者误操作导致固件损坏。这种情况做好BOOT引脚处理和读保护就够了。
  • 防维修/售后团队:有时候返修回来的板子,厂家自己也要刷程序,那就不能锁太死,需要预留受控的升级通道。

另外还要想清楚一个矛盾:你封死了SWD和ISP,那自己工厂量产的时候怎么烧程序?后面售后要升级固件怎么办?这些问题如果不在设计阶段想明白,等产品已经做了几百套再改,麻烦就大了。所以我会在文章后半部分专门讲“量产顺序”和“OTA升级”的配合方案。

2. 核心防线:HAL_FLASH_Program一族函数改写选项字节

2.1 先分清HAL库Flash编程三兄弟

很多人一看到标题里的HAL_FLASH_Program就问:“这函数不是写应用Flash的吗?跟保护有什么关系?”这里有一个很常见的误区。HAL库提供了多个Flash操作相关的函数,各有分工:

函数名作用说明
HAL_FLASH_Program编程主Flash区写应用程序代码、写参数数据
HAL_FLASH_OBProgram编程选项字节区设置读保护、写保护、BOOT配置等
HAL_FLASH_OB_Unlock / HAL_FLASH_Unlock解锁对应区域写入访问密钥,防止误操作

你可以在代码里调用HAL_FLASH_Program来写IAP升级程序,但“禁止通过SWD和ISP升级”这件事,真正干活的其实是HAL_FLASH_OBProgram——它专门用来改写选项字节。很多教程不区分这两个函数,导致开发者以为烧录保护就是调用一下HAL_FLASH_Program。理解这对兄弟函数的区别,是搞懂本篇内容的第一步。

选项字节(Option Bytes)是STM32里一个独立的配置区域,可以理解成芯片的“系统设置项”。它里面的RDP(Read Protection)字段,就是控制代码保护级别的总开关。通过修改这个字段,你可以让调试器和ISP都无法读取、写入主Flash。

2.2 读保护RDP三级机制:0、1、2的区别

STM32的读保护分三个级别,这是整个保护方案的核心基础。我直接用一张表说清楚:

RDP级别名称调试口访问ISP访问能否恢复典型场景
Level 0无保护完全开放完全开放—开发调试阶段
Level 1读保护无法读Flash/RAM,只能连接做擦除无法读Flash,但可执行全片擦除可恢复,但恢复时自动全片擦除量产成品标准配置
Level 2永久保护调试口彻底失效彻底失效不可恢复,永久锁死高度保密产品/终极熔断

这里有两个细节特别重要:

第一,Level 1并不是“不能连接调试器”,而是“连接后无法访问主存储区”。实际表现是:调试器能识别到芯片,但读内存出来的数据全是零或者无效,烧录操作被拒绝。很多开发者上了Level 1之后发现还能连上调试器,就以为保护没生效,其实保护已经起作用了——只是“能看见”不代表“能拿到数据”。

第二,Level 1有一个设计上的“后门”,就是允许把RDP从Level 1降回Level 0,但代价是硬件会自动触发全片擦除(Mass Erase)。也就是说,即使有人找到了降级的方法,他得到的也只是一颗空片,你的固件已经被硬件自动抹掉了。这个机制不是漏洞,反而是防抄板的核心保障。想清楚这个逻辑:你不是要防止别人“擦掉固件”,而是要防止别人“读出固件”。擦掉不可怕,读出来才是灾难。

2.3 设置RDP Level 1的标准代码

以STM32F4系列、HAL库为例,设置读保护Level 1的完整流程如下:

/* 1. 解锁Flash和选项字节区域 */ HAL_FLASH_Unlock(); HAL_FLASH_OB_Unlock(); /* 2. 配置读保护级别 */ FLASH_OBProgramInitTypeDef obConfig; obConfig.OptionType = OPTIONBYTE_ROP; obConfig.RDPLevel = OB_RDP_LEVEL_1; /* 3. 写入选项字节 */ uint32_t status = HAL_FLASH_OBProgram(&obConfig); /* 4. 上锁并复位 */ HAL_FLASH_OB_Lock(); HAL_FLASH_Lock(); if (status == HAL_OK) { /* 复位后选项字节生效 */ NVIC_SystemReset(); }

注意,不同系列的API写法有差异。比如在STM32F1系列上,代码更接近:

FLASH_Unlock(); FLASH_OB_Unlock(); FLASH_OB_RDP_LevelConfig(OB_RDP_LEVEL_1); FLASH_OB_Lock(); FLASH_Lock();

所以抄代码的时候一定要对应自己的芯片型号和HAL版本,别直接拿F4的函数往F1上套,会编译报错或者行为不符合预期。你可以在CubeMX生成的工程里找到对应的HAL头文件,先确认有哪些OB相关的函数原型,再决定怎么写。

2.4 Level 2:真正的“物理熔断”

如果产品对保密要求极高,可以在量产时直接设置RDP Level 2。这个级别下,芯片的所有调试接口、ISP引导、选项字节读取/修改全部失效,而且是永久性的,没有任何恢复手段。可以说,设了Level 2,芯片就从一个“可编程设备”变成了“只读设备”,连你自己都动不了它。

设置Level 2的代码和Level 1基本一样,只改一个宏:

obConfig.RDPLevel = OB_RDP_LEVEL_2;

但我要反复强调:严禁在开发调试阶段设置Level 2。一旦设置,这个芯片就废了,你以后想改固件、想调bug、想复用这颗料做其他项目,都无能为力。我身边就有朋友在测试读保护功能时,手一抖写了Level 2,结果整块板子变成砖头,只能扔了换新片。

量产的流程应该是:所有固件下载、功能测试、参数校准都做完之后,最后一步才写Level 2。而且要写一个独立的量产工具脚本,不要让产线工人手动在调试界面里选Level 2,免得选错。

2.5 运行时动态上锁:程序跑起来后自己锁死

除了在烧录阶段用调试器设置RDP,还有一种实用场景:固件运行过程中,检测到某个条件后,主动调用HAL_FLASH_OBProgram把自己锁起来。

举个例子,你的产品有密码保护功能,用户连续输错三次密码。这时候可以在代码里判断出错次数,然后把RDP从Level 0直接改到Level 1:

if (password_error_count >= 3) { /* 输入错误次数过多,锁死芯片,防止暴力破解 */ HAL_FLASH_Unlock(); HAL_FLASH_OB_Unlock(); FLASH_OBProgramInitTypeDef lockConfig; lockConfig.OptionType = OPTIONBYTE_ROP; lockConfig.RDPLevel = OB_RDP_LEVEL_1; HAL_FLASH_OBProgram(&lockConfig); HAL_FLASH_OB_Lock(); HAL_FLASH_Lock(); NVIC_SystemReset(); /* 复位后锁生效 */ }

这种做法的意义在于:它让“上锁”这件事不依赖外部烧录器,而是产品自己掌握自己的命运。哪怕别人没有通过SWD入侵,仅仅是想通过应用层的漏洞提取数据,动态上锁也能打断他的后续操作。

但这里也有一个坑:程序一旦调用这个函数,而且复位之前没有写好特殊标记,后果就是板子从此以后不能再调试了。如果这是开发过程中的测试板,你就等着全片擦除程序重来吧。所以动态上锁的代码一定要加条件编译,只在正式版固件里启用,开发版保持开放。

3. 堵死SWD端口:两种禁用方式的取舍

3.1 方式A:靠RDP硬件级封锁(推荐)

在前面讲了RDP之后,你应该能明白:真正可靠地禁掉SWD,不是把引脚配置成GPIO,而是设置RDP Level 1或Level 2。RDP封锁是芯片硬件层面的行为,跟你的单片机里跑什么代码没有关系。芯片一上电,硬件就会自动检查RDP状态,一旦检测到Level 1,调试访问就被拦截;Level 2更是直接把调试端口整个关掉。

这里有个实操经验想分享:很多人测试读保护效果时发现SWD还是“能连上”,心里发虚。其实这不是保护失效,而是调试器在握手阶段还是能识别芯片,只有等你实际执行读内存、写Flash操作时才会被拒绝。判断保护是否生效,最简单的办法是用烧录工具连接之后执行“读Flash”操作,如果读取失败或者读到的全为空,说明保护已经生效。

3.2 方式B:把SWD引脚复用成GPIO(不推荐单独用)

网上有不少教程教你怎么“关闭SWD”,方法是把PA13、PA14(STM32F1上对应SWDIO、SWCLK)复用为普通GPIO。代码也很简单:

GPIO_InitTypeDef gpio; gpio.Pin = GPIO_PIN_13 | GPIO_PIN_14; gpio.Mode = GPIO_MODE_ANALOG; HAL_GPIO_Init(GPIOA, &gpio);

但我要严正提醒:这种方式只能让你的调试口“暂时失效”,防不住真正想破解的人。为什么?因为芯片复位后、用户程序还没跑起来的那个短暂窗口期,SWD端口依然是默认的调试功能。破解者只要拉低复位引脚,在复位释放的同时紧接着发起调试连接,就能在用户代码运行之前抢到调试权限。这种“复位毛刺攻击”在嵌入式安全圈里是基本操作,单纯靠复用引脚挡不住。

另外,把SWD引脚复用成GPIO,意味着你自己也没法调试了。如果你的程序跑了几天之后出了bug,需要查看现场,结果发现没法连调试器,只能靠日志输出一点点排查,那种痛苦我不希望你来体验。

所以我的结论是:SWD复用引脚这个方案,最多只能作为临时手段,不能作为产品防护的主防线。真正要依赖的还是RDP的硬件封锁。

3.3 为什么有人禁了SWD还是被破解

这里说一个常见的认知落差。有人觉得“我禁了SWD,所以固件安全了”,这是错的。因为固件被破解的途径不止SWD一个:ISP串口引导、应用层的串口/网络协议漏洞、外部Flash芯片数据泄露,甚至直接通过JTAG边界扫描测试设备(在某些测试模式下)都有可能成为泄露口。SWD只是其中一条路,你把路堵了,别人还可以绕路。这也是为什么完整的防护方案必须组合拳:SWD封锁、ISP封锁、数据加密、代码区域保护一起上。

4. 封死ISP串口引导:软硬件结合的方案

4.1 硬件层面:BOOT0引脚必须彻底趴地

前面说了,ISP模式的进入条件是BOOT0=1(部分系列还要考虑BOOT1)。那最直接的防御就是在硬件设计上让BOOT0永远处于低电平。

常规做法是在BOOT0引脚上串联一个10k下拉电阻,确保上电和复位时BOOT0被稳定拉低。注意这个电阻必须放在靠近引脚的地方,而且下拉要足够强(10k到100k都可以,但不要用太大的阻值,避免噪声干扰导致误判)。

更进一步,如果你的产品PCB允许,可以考虑把BOOT0的过孔或者焊盘隐藏起来,比如放在BGA芯片底下、或者铺铜盖住,增加别人飞线触碰的难度。我见过一个产品,把BOOT0焊盘直接做成0欧电阻连接,调试时焊上电阻可以进ISP,出厂时把电阻去掉并涂上三防漆。这个思路比较实用,量产和保密兼顾。

4.2 软件层面:选项字节强制主Flash启动

在STM32F4等系列上,BOOT0引脚的行为是可以被选项字节控制的。通过把nBOOT0这个选项位配置为1,可以让芯片在选择启动方式时忽略BOOT0引脚的硬件电平,强制从主Flash启动。这样,即使别人把BOOT0引脚强行拉高,芯片也不会进入ISP模式,而是照常执行主Flash里的用户程序。

配置nBOOT0的代码本质上还是写选项字节,类似这样:

FLASH_OBProgramInitTypeDef bootConfig; bootConfig.OptionType = OPTIONBYTE_USER; bootConfig.USERConfig = OB_BOOT0_FROM_MAIN_FLASH; HAL_FLASH_OBProgram(&bootConfig);

需要说明的是,不同系列的USER选项字节定义不一样,一定要查对应型号的参考手册里关于BOOT配置的表格。比如有些系列是通过BOOT1或者nBOOT1来决定启动模式的,逻辑不完全相同。我把这个功能列出来的目的是告诉你:硬件拉低BOOT0不是唯一手段,芯片给了你软件层面的控制权,善用它能进一步提高防护强度。

4.3 封锁ISP之后,量产和售后怎么烧录

这是很多人卡住的地方。你把SWD和ISP都封了,工厂怎么烧固件?售后怎么升级?

我的处理方式是这样权衡的:生产阶段用烧录器烧录,不封;功能测试和校准之后,再由专门的程序烧写进去,设置RDP Level 1(或者更高级别)。RDP一旦设置为Level 1,主Flash不可读,调试连接也会拒绝访问,但生产线上保留一个特殊工位,用于需要返工时的全片擦除。

售后升级走OTA或者IAP。具体来说,在固件初始版本里内置一个IAP Bootloader,通过约定的协议接收新的固件,用HAL_FLASH_Program把新程序写入另一个Flash区域,校验通过后跳转。这个通道完全在应用层控制,不依赖SWD和ISP。这样既保证了产品正常生命周期内的可维护性,又把外部的硬件调试通道彻底封死。

5. 进阶组合:防降级、防绕过、防拆芯片

5.1 为什么“全片擦除”反而保护了你

前面提到,Level 1降到Level 0会触发全片擦除。这个机制我再说透一点:它是硬件自动执行的,不经过你的用户程序,也不受外部调试器控制。也就是说,破解者如果想通过“降级”的方式把RDP从Level 1改回Level 0,芯片会先把整个主Flash区域清空,然后才允许修改选项字节。

这里有一个很微妙的点:全片擦除对破解者来说,是一种“杀敌一千自损八百”的行为。他想拿到的是你的代码,但成功降级之后得到的是空片,什么都没拿到。所以RDP Level 1的设计思路很清晰——保护的不是“代码永远不消失”,而是“代码无法被完整读出”。哪怕最终被人擦掉了,他也没得到任何可复用的东西。

5.2 PCROP:给核心算法单独上一把锁

RDP保护的是整个主Flash的读取权限。但有些产品需求更精细:我希望Flash大部分区域可以被读取(比如为了日志导出、配置文件升级),只有一小部分核心算法区域严防死守。这时候就轮到PCROP(Proprietary Code Read Out Protection)上场了。

PCROP允许你在Flash里指定一块区域,设置后这块区域的内容既不能被调试器读取,也不能被ISP读取,连通过用户程序本身访问都受限制。它非常适合存放AES密钥、RSA私钥、自定义加密算法这类核心资产。配置方式同样是通过HAL_FLASH_OBProgram,指定区域起始地址和结束地址,然后调用带PCROP配置的选项字节写入函数。

使用PCROP要注意的是:这个区域一旦设置了保护,固件里对这个区域的任何读取操作都会被硬件拦截返回零,所以那些密钥数据一定要在受保护区域内,但整个代码逻辑中又需要能访问到它。这里有个处理技巧:可以在启动阶段把PCROP区域的数据拷贝到SRAM中,然后程序后续就只跟SRAM里的副本打交道。这样既保证了静态存储时的安全性,又不影响运行时的效率。

5.3 固件校验与签名验证:防止“改固件”式的二次入侵

前面讲的是防止“读”,还有一个维度是防止“改”。别人虽然没有读出你的代码,但如果你预留了IAP升级通道,而他逆向分析了通信协议,伪造一个升级包把恶意固件刷进去,后果一样严重。

所以我在设计IAP方案时,一定会加两层校验:固件包内的CRC32校验和RSA签名验证。CRC32能防止传输损坏,RSA签名能防止伪造升级包。升级流程是:Bootloader收到完整固件包后,先校验CRC,再用内置公钥解签名,确认升级包确实来自厂商,才允许写入。签名验证通过之后,调用HAL_FLASH_Program把数据写到应用区,最后跳转执行。

这里提醒一个细节:签名校验用的公钥如果存放在主Flash里,别人可以通过替换Bootloader的方式绕过签名验证。所以公钥要么放在PCROP保护区,要么放在OTP(One-Time Programmable)区域。哪怕Bootloader被篡改,错误的公钥也会导致升级包验证失败,从而保护整个升级链路。

6. 实操避坑:上锁过程常见问题和自救速查

6.1 锁死后调试器连不上,先分辨是哪一种“连不上”

我遇到过不少新手,设置读保护之后发现Keil报错,整个人就慌了,以为自己把芯片搞废了。其实大部分情况下芯片根本没废,只是被“锁住”了。不同级别、不同工具的报错表现不一样,我整理了一个速查表:

症状可能原因处理方案
调试器完全找不到设备芯片已设置RDP Level 2无法挽救,换新芯片
能识别设备,但读Flash失败/为空RDP Level 1生效属正常现象,说明保护成功
能连接但无法烧录,烧录工具提示“Flash下载失败”RDP Level 1先用“全片擦除”功能恢复再烧录
ISP连接超时,无法握手芯片未进入ISP模式,或RDP Level 1检查BOOT0电平、串口接线;确认RDP状态
升级后芯片无法运行启动方式配置错误或固件跳转地址错误检查选项字节BOOT配置、源程序启动地址

6.2 不小心把自己锁死了怎么办

万一你在开发阶段误触发了读保护,而上锁的芯片里又存着还没备份出来的固件,先别急着扔。绝大多数支持STM32的烧录工具都提供“解除保护”功能,操作的本质就是对芯片执行全片擦除并清除RDP选项字节。代价是固件和所有数据都没了,但芯片本身还能重新烧录。

具体操作思路:用调试器连接芯片,在烧录软件里选择“Erase Chip”或者“Option Bytes”模式下的“Unprotect”,软件会尝试将RDP写回Level 0。这个过程会触发芯片硬件全片擦除,完成后芯片恢复为出厂状态,重新烧录固件即可。如果是通过ISP模式解除,注意BOOT0要拉到高电平让芯片进入系统存储器引导,然后通过串口协议发送Mass Erase命令。同样,数据全没了,但芯片能救回来。

6.3 量产顺序:锁保护必须放在最后一步

给产品做防护,最忌讳的就是过早锁死。我见过一个团队,在样机验证阶段就灌了RDP Level 1的代码,结果后续开发中每次调试都要先全片擦除再重新烧录,折腾到怀疑人生。合理的量产分工是这样的:

  1. 硬件贴片完成后,先用烧录器下载Bootloader和应用程序;
  2. 进行功能测试、老化测试、参数校准,这个阶段保持芯片完全开放;
  3. 所有测试通过后,在产线最后一道工序执行“产品固化”操作,内容包括:烧写唯一的序列号、设置RDP Level 1(或Level 2)、配置启动选项字节;
  4. 固化完成后抽检几台,验证一下调试口是否确实无法访问、ISP是否被堵死。

这个流程经历了多轮的验证,生产节拍的额外损耗也只有几秒钟,但产品的安全级别完全是另一个层次。

6.4 我实际操作中的几点体会

做代码保护这些年,踩过的坑不少,说几个自己觉得有价值的经验。

第一,保护方案一定要分等级。不是所有产品都需要Level 2的“物理熔断”,大多数产品做到Level 1就够了——防住了普通人,也不想让自己售后无路可退。真正需要Level 2的产品,一般是核心算法价值极高、且量产后不需要任何现场升级的,才值得牺牲掉可维护性。

第二,别迷信“把所有口都封死”就是最安全。产品安全是一个系统工程,SWD和ISP只是外部通道。如果你的系统里有外部Flash、有无线通信模块、有应用层软件漏洞,一样可以泄露敏感信息。千万不要因为设了RDP就放松其他维度的安全设计。

第三,保存好每颗芯片唯一的标识信息。量产的时候把芯片的96位唯一ID、固件版本、烧录时间等数据记录到数据库里。后面如果发现某台设备被人逆向,可以通过唯一ID追溯到批次和烧录环节,这对追责和改进防护方案都非常有帮助。

最后送给大家一个小技巧:量产固化程序时,写一个脚本,通过命令行调用烧录工具,把“烧录固件、写序列号、设置RDP、校验结果”这几个动作串起来,一次性完成。手工在软件界面里点来点去容易出错,尤其容易误选RDP Level 2导致整批芯片报废。脚本化之后,这个环节的操作风险会降到最低。希望这篇内容能帮你在保护自己产品的同时,少踩一些我当年踩过的坑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进