Keil4老STM32工程手工移植mbedTLS并跑通TLS握手
手上有个跑了三五年的 STM32 老工程,编译器还停在 Keil4 那一套(uVision4 + ARMCC V5),启动文件、外设库、中断向量全是当年一行行抠出来的,谁也不想为了一个新需求把它整个迁到新工具链上。可偏偏产品现在要接一个 HTTPS 接口拉配置,或者把采集数据经 MQTT over TLS 推到云端,安全层这块躲不掉了。这时候大多数人的第一反应是搜"Keil 添加 mbedTLS",搜出来的结果十有八九是 CubeMX 勾两下、Keil5 里 RTE 组件点上 mbedTLS、LwIP 一配,编译通过。问题是 Keil4 里根本没有 RTE 那套组件管理机制,也没有 Pack Installer,你面对的是一个空的工程树和一堆 .uvproj 配置项,mbedTLS 这个东西又不像 FatFs 那样只有几个文件丢进去就能跑——它是一整套源码、一份需要手工裁剪的配置头文件、外加若干必须你自己填的移植桩函数。
这篇就把"在 Keil4 的老 STM32 工程里手工把 mbedTLS 加进去并跑到握手成功"这件事从头拆一遍。适合手上握着 Keil4 工程、不愿意或者没条件迁移工具链、又确实需要在 MCU 上跑 TLS 的开发者;也适合那些在 Keil5 里点组件点成功了、但完全不知道背后发生了什么、一旦报错就抓瞎的人——手工加一遍,你会把这套库的骨架看得清清楚楚。我会把版本选择、文件取舍、配置裁剪、三个移植桩、以及联调阶段那一串负数值错误码的排查链路都写进来,都是踩过之后才明白的东西。
1. Keil4 里加 mbedTLS 为什么比想象中麻烦
1.1 Keil4 与 Keil5 的工程模型差异
先说清楚障碍在哪。Keil5(MDK5)引入的 RTE 组件机制,本质上是把"源码从哪来、头文件路径是什么、需要开哪些宏"这套知识固化成了 .pdsc 描述文件,你勾一下,工具自动把文件、包含路径、预定义宏全填好。mbedTLS 在 Pack 里有现成的描述,所以 Keil5 用户几乎不需要理解库结构。
Keil4 用的是 .uvproj 工程文件,没有 RTE,工程里的每一个 .c 文件、每一条 Include Path、每一个 Define 都要你自己动手加。这意味着两件事:第一,你必须知道 mbedTLS 到底由哪些文件组成、哪些是必需的、哪些可以扔;第二,你必须知道这些文件依赖哪些宏开关,否则编译过去也是满地错误。
好处也不是没有。手工加一遍之后,你对这套库的依赖关系会非常清楚,后面做体积裁剪、做硬件加速替换、排查内存问题时,比点组件的人有底气得多。而且 Keil4 的工程结构简单直白,出问题的时候少一层"工具帮你做了什么"的黑盒。
1.2 mbedTLS 到底是几个东西拼起来的
很多人以为 mbedTLS 是个 .lib,加进去链接就行。不是。mbedTLS 的发行包解开之后,跟你相关的只有三块:
| 目录 | 内容 | 在 Keil4 里的处理 |
|---|---|---|
library/ | 全部 .c 实现,约六十个文件,涵盖大数运算、对称加密、哈希、X.509 解析、TLS 状态机 | 加入工程,靠配置裁剪 + 链接器去冗余 |
include/mbedtls/ | 对应的头文件,以及最关键的config.h | 加入 Include Paths,config.h 要改 |
programs/、tests/ | 示例程序和自测代码,依赖文件系统、socket、命令行 | 一律不加,加进去必然编译不过 |
除此之外还有一层"移植层",也就是那几个必须由你提供实现的函数:随机数熵源、时间获取、网络收发。这三个东西在 PC 上有现成实现(读 /dev/urandom、gettimeofday、BSD socket),在裸机 MCU 上一样都没有,全靠你填。这三个桩填不对,库能编译过,但握手一定失败,而且报错信息往往指向别处,非常容易误判。
1.3 版本选择:别一上来就拉最新
这一步很多人栽跟头。mbedTLS 3.x 相比 2.x 做了大量 API 清理,移除了一堆废弃接口,对 C99 的要求也更严格。ARMCC V5 这个编译器对 C99 的支持是"够用但不完整",3.x 里部分代码用到的语法特性和内建属性在 armcc5 上会直接报错,你得一个个去改,改完还不保证行为正确。
我的建议很明确:Keil4 + ARMCC5 的组合,选 2.16.x 的 LTS 分支或者 2.28.x 的 LTS 分支。2.16 系列是这个编译器上验证最充分的,网上能查到的报错案例也最多;2.28 也可以,功能更全一些,但个别文件需要打小补丁。3.x 就別在这套工具链上折腾了,投入产出比太低。
选定版本之后,把发行包解压到一个固定的第三方目录,比如工程根下建third_party/mbedtls-2.16.10/,把library和include两个目录拷进去。不要把整个发行包丢进工程目录,programs和tests里有几百个文件,Keil4 的工程窗口会被撑爆,而且查找文件极不方便。
2. 动手之前先算三笔账:编译器、Flash、RAM
2.1 ARMCC5 的 C99 开关与几个隐性坑
Keil4 的 Options for Target 里,C/C++ 选项卡有一个 C99 Mode 的复选框,必须勾上。mbedTLS 的代码里到处是行内声明变量、//注释、stdint.h定宽类型,不开 C99 直接一片红。如果你习惯在 Misc Controls 里手写参数,等价的是--c99。
光开 C99 还不够,还有几个坑要提前知道:
第一,snprintf。mbedTLS 的证书解析、调试输出里会用到它。ARMCC5 的标准库里有,但如果你勾了 Use MicroLIB,微库对snprintf的支持是不完整的,某些格式符会输出异常甚至崩。稳妥做法是定义MBEDTLS_PLATFORM_SNPRINTF_ALT,自己挂一个实现上去,或者干脆不要开 MicroLIB。
第二,time.h。库里有几处用到time_t和time(),裸机上根本没有。不处理会在链接阶段报time未定义。解决办法在后面配置裁剪那章讲,用MBEDTLS_PLATFORM_TIME_ALT挂自己的实现,或者直接关掉时间相关特性。
第三,__attribute__((weak))。armcc5 是支持的,但语法位置敏感,如果某个文件报这个属性相关的错,多半是宏定义被覆盖了,检查一下工程里有没有全局的#define __attribute__(x)之类的脏东西。
第四,警告等级。mbedTLS 源码在 armcc5 下会产生大量"变量定义了未使用""有符号无符号比较"之类的警告。别一开始就追求零警告,那会耗掉你两天时间。把工程警告等级先降到 Level 1,等功能跑通了再回头看真正有价值的那几条。
2.2 Flash 与 RAM 的预算表,心里要有数
mbedTLS 不是个小东西。默认配置全开的情况下,Flash 占用能到 200KB 以上,单次 TLS 会话的 RAM 占用超过 30KB。这在 STM32F103 这类 64KB Flash / 20KB RAM 的片子上是完全放不下的,必须裁剪。
下面这张表是我在几个常见配置下的实测参考值,用的是 ARMCC5 编译、开启链接器未用段清除、TLS 1.2 单向认证、RSA-2048 + AES-128-GCM + SHA-256 这套组合:
| 配置档位 | Flash 增量 | 单会话 RAM | 适用芯片 |
|---|---|---|---|
| 全默认配置 | 约 190–220 KB | 32 KB 以上 | 基本不现实 |
| 中度裁剪(关调试、关文件 IO、关自测、保留 x509 解析) | 约 60–75 KB | 18–22 KB | 256KB Flash / 64KB RAM 起 |
| 极限裁剪(关 x509 解析、预置公钥、内容缓冲降到 2KB) | 约 32–40 KB | 8–11 KB | 128KB Flash / 32KB RAM 勉强 |
这张表的意义在于,你得先确认目标片子扛不扛得住,再决定是否继续。如果芯片 RAM 只有 20KB,还要跑 RTOS 和其他任务,那整条 TLS 链路在片内跑就不合适了,得考虑外挂一颗带 TLS 卸载的通信模组,让 MCU 只发 AT 指令。这个判断要放在动手之前,不然移植完了发现内存不够,前面的时间全白费。
2.3 工程目录与文件组织方式
目录规划我推荐这样:
project/ ├── app/ 用户代码 ├── drivers/ HAL 或标准外设库 ├── third_party/ │ └── mbedtls-2.16.10/ │ ├── include/mbedtls/ 头文件与 config.h │ └── library/ .c 实现 └── port/ ├── mbedtls_port_entropy.c 熵源移植 ├── mbedtls_port_time.c 时间移植 └── mbedtls_port_net.c 网络收发移植把移植代码单独放在port/下面,和第三方源码彻底分开,好处是将来升级 mbedTLS 版本时,直接把third_party整个换掉就行,你的移植层一行不用改。这个习惯一旦养成,后面维护成本会低很多。
3. 把源码真正塞进 Keil4 工程树
3.1 文件分组与最小文件集的取舍
在 Keil4 的 Project 窗口里右键新建两个 Group,一个叫mbedtls,一个叫mbedtls_port。前者放库源码,后者放你自己的移植文件。
往mbedtls组里加文件有两种流派。流派一是把library/下所有 .c 全加进去,靠 config.h 裁剪和链接器的未用段清除来控制体积。流派二是先按功能筛一遍,只加必需的文件。我倾向流派一,理由很实在:mbedTLS 内部文件之间互相引用非常密集,你手工筛的时候漏掉一个,编译能过、链接报未定义符号,然后你得回头去查这个符号在哪个文件里,来来回回非常消耗耐心。全加进去,编译时间会长一点,但一次到位。
不过有几个文件是明确不需要的,可以直接不加,能省一点编译时间:
net_sockets.c:依赖 BSD socket 头文件,Keil4 环境里没有,加了必然报错。timing.c:依赖sys/time.h或 Windows 头文件,同样编不过。fs_io.c:依赖fopen系列,裸机上没有文件系统。ssl_ticket.c:会话票据功能,用到 AEAD 和额外的密钥管理,绝大多数 MCU 场景用不上。ssl_cache.c:会话缓存,除非你要做多客户端复用。
其余文件都加上。加完之后第一次编译,一定会报一堆错,别慌,下一节按错误类型逐个处理。
3.2 Include Paths 与预定义宏的填写方式
Include Paths 里至少要有两条:third_party/mbedtls-2.16.10/include,以及如果需要自定义配置,再加一条你自己放 config.h 的目录。
这里有个细节容易翻车:Keil4 的路径是相对于 .uvproj 文件所在目录的,但如果你用了绝对路径,换台机器或者换个盘符就编不过。养成全部用相对路径的习惯,路径分隔符用反斜杠或正斜杠都行,Keil 两种都认。
预定义宏这一栏(Define 框),功能裁剪阶段会往里加东西,初期可以先空着。但有两条建议一开始就加上:MBEDTLS_CONFIG_FILE="my_config.h"用来指向你自己的配置文件(这样升级库版本时不会覆盖你的配置),以及MBEDTLS_USER_CONFIG_FILE="user_config.h"用来做增量覆盖。这两个宏在 2.x 系列都支持,用起来很省心。
3.3 首轮编译错误对照与处理
第一次编译大概率会停在下面这几类错误上,我把处理方式列成表,你照着改就行:
| 报错关键字 | 根因 | 处理方式 |
|---|---|---|
cannot open source input file "sys/time.h" | 编译了 timing.c 或 net_sockets.c | 从工程里移除这两个文件,或定义MBEDTLS_TIMING_ALT、禁用MBEDTLS_NET_C |
identifier "time_t" is undefined | 未开启时间支持但代码引用 | 关闭MBEDTLS_HAVE_TIME、MBEDTLS_HAVE_TIME_DATE,或用MBEDTLS_PLATFORM_TIME_ALT |
undefined symbol time/snprintf | 库函数在裸机上缺失 | 定义MBEDTLS_PLATFORM_TIME_ALT/MBEDTLS_PLATFORM_SNPRINTF_ALT并实现对应函数 |
#error "MBEDTLS_PLATFORM_C is required" | 依赖顺序问题 | 在 config.h 里确保MBEDTLS_PLATFORM_C开启 |
expected a ")"出现在宏展开处 | C99 未开启或旧语法 | 确认--c99生效 |
section .bss exceeds或 RAM overflow | 默认配置内存占用过大 | 进入第 4 章做配置裁剪与缓冲区缩小 |
这张表覆盖了我遇到过的八成首轮问题。剩下的两成通常和你自己工程的既有宏冲突有关,比如你的工程里已经定义了MBEDTLS_前缀的宏,或者有全局的#define min/max之类。遇到诡异报错,先检查工程全局宏定义列表,这是个常被忽略的地方。
4. config.h 裁剪:把几百 KB 的库压进单片机
4.1 从默认配置到最小可用配置的裁剪思路
mbedTLS 的 config.h 是一份注释极其详尽的清单,每个宏上面都写着"这个功能是什么、依赖谁、开多大"。默认状态下它基本是"全开",那是给 PC 平台准备的。裁剪的思路不是去背哪些宏该关,而是反过来问自己三个问题:
第一个问题,我要连的服务端用什么加密套件?如果你能控制服务端配置(自建服务),直接把套件锁死到TLS-ECDHE-RSA-WITH-AES-128-GCM-SHA256这一类,那么对称算法只需要留 AES 和 GCM,哈希只需要留 SHA-256,密钥交换只需要留 ECDHE,椭圆曲线只需要留 secp256r1。这一刀下去,Flash 能砍掉一半以上。
第二个问题,证书怎么验证?完整验证需要 X.509 解析器(MBEDTLS_X509_USE_C、MBEDTLS_X509_CRT_PARSE_C)、ASN.1 解析器、PEM 解析器,还要有时间函数做有效期检查。如果你采用"预置服务端公钥、跳过证书链验证"的方案,这一整块都可以关掉,代价是失去对中间人攻击的防护——这个取舍要看产品形态,内网设备和服务端都在你手里,风险和公网设备完全不是一个量级。
第三个问题,要不要调试输出?MBEDTLS_DEBUG_C加上MBEDTLS_ERROR_C能让你看到详细的握手流程日志和错误码中文解释,调试阶段非常有用,但会占十几 KB Flash。我的做法是调试版全开,发布版全关,用两个 Target 配置切换。
4.2 关键宏的取舍清单
照着下面这张表改,基本能得到一个能跑、体积可控的配置。左边是宏名,中间是建议值,右边是我为什么这么选:
| 宏名 | 建议 | 理由 |
|---|---|---|
MBEDTLS_SSL_PROTO_TLS1_2 | 开 | 只保留 TLS 1.2,TLS 1.0/1.1 现代服务端基本已禁用,1.3 在 2.x 上实现不完整 |
MBEDTLS_SSL_PROTO_DTLS | 关 | 除非做 CoAP over DTLS,否则用不上 |
MBEDTLS_SSL_SRV_C | 关 | MCU 通常作客户端,关掉服务端能省一大块 |
MBEDTLS_SSL_CLI_C | 开 | 必需 |
MBEDTLS_KEY_EXCHANGE_RSA_ENABLED | 关 | 静态 RSA 密钥交换没有前向安全性,现代服务端多已弃用 |
MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED | 开 | 主流组合,兼容性最好 |
MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED | 视情况 | 服务端用 ECC 证书时开启 |
MBEDTLS_ECP_DP_SECP256R1_ENABLED | 开 | 其余曲线全关,只留这一条 |
MBEDTLS_AES_C+MBEDTLS_GCM_C | 开 | 现代套件的主流组合 |
MBEDTLS_CIPHER_MODE_CBC | 关 | 没有 CBC 套件就不需要,能省不少代码 |
MBEDTLS_SHA256_C | 开 | 必需 |
MBEDTLS_SHA1_C | 关 | 除非服务端证书链里有 SHA-1 签名的中间证书 |
MBEDTLS_MD5_C | 关 | 同上,能关就关 |
MBEDTLS_BIGNUM_C | 开 | RSA 和 ECC 都依赖,关不掉 |
MBEDTLS_ENTROPY_C | 开 | 随机数质量是安全底线 |
MBEDTLS_SSL_MAX_CONTENT_LEN | 4096 或 2048 | 默认 16384 太大,这条对 RAM 影响最直接 |
最后一条要特别强调。MBEDTLS_SSL_MAX_CONTENT_LEN决定了 in/out 两个缓冲区的大小,默认 16384 意味着光这两块就吃掉 32KB RAM。改成 4096,两个缓冲区降到 8KB,总 RAM 立刻从三十几 KB 降到十几 KB。代价是单条记录最大只能传 4KB 明文,超过就要分片。对 MQTT 这类小包协议完全够用,对 HTTP 下载大文件就不合适了。2.x 后期版本还支持分别设置MBEDTLS_SSL_IN_CONTENT_LEN和MBEDTLS_SSL_OUT_CONTENT_LEN,可以把发出去的 buffer 设得更小,因为客户端上行数据通常很少。
4.3 裁剪之后的验证:看 map 文件而不是靠感觉
改完配置重新编译,通过之后不要急着高兴,去工程输出目录找.map文件,搜索mbedtls前缀的符号,把各个模块的占用列出来。这一步能发现两类问题:
一类是"以为关了其实没关"。有些特性之间存在隐式依赖,你注释掉了主宏,但某个子宏还在,或者相反,某个你注释掉的宏被另一个开着的宏内部重新定义。map 文件里看到一堆 X.509 相关的符号还在,就说明没关干净,回去查依赖关系。
另一类是"局部过大"。比如你发现ecp_curves.o占了十几 KB,那是因为多条椭圆曲线没关;bignum.o占了二十几 KB,那是正常的,大数运算是刚需,除非你上硬件加速。
Keil4 里还有个更直观的办法:编译完成后看 Build Output 窗口最后那行Program Size: Code=xxxxx RO-data=xxxx RW-data=xxxx ZI-data=xxxx。把加了 mbedTLS 前后的数字一减,Flash 和 RAM 的净增量就出来了,比任何估算都准。我习惯每次大改配置都记一笔这个数字,形成自己的经验曲线。
5. 三个必须自己填的移植桩
5.1 熵源:随机数质量决定了整条链路的安全下限
TLS 握手里的密钥、随机数、IV 全部依赖一个高质量随机源。mbedTLS 的熵模块(MBEDTLS_ENTROPY_C)本身是个收集器,它需要至少一个"熵源"喂数据,PC 上用的是硬件熵池或/dev/urandom,MCU 上你要提供。
如果你的 STM32 带硬件随机数外设(RNG),那是最好的情况。初始化 RNG 时钟、使能外设、写回调函数,把 4 字节的随机数读出来喂给 mbedTLS。注意 RNG 外设有启动时间,第一次读之前要等DRDY标志置位,并且要检查状态寄存器里的种子错误标志(SEIS)和时钟错误标志(CEIS),出错就复位重来。这几个检查很多例程都省了,实际产品里出过一次随机数全零的故障,整个批次设备握手都失败,排查了两天才定位到是上电时序问题导致 RNG 没起来。
没有硬件 RNG 的片子,只能用软件熵源兜底。常见做法是采集 ADC 悬空引脚的低位噪声、内部温度传感器的末位抖动、或者 RC 振荡器计数的微小偏差。这种熵的质量远不如硬件 RNG,但聊胜于无。mbedTLS 允许挂多个熵源,你可以在硬件 RNG 之外再挂一个软件熵源做混合,这样即使硬件源出问题,整体熵也不会完全塌陷。
还有一种情况:你的芯片没有 RNG,也不想折腾软件熵源,直接把一个预置的固定种子喂给 CTR-DRBG。这样能跑起来,握手也能成功,但随机数可预测,安全性等于零。做功能验证可以,上产品绝对不行,这一点要心里有数。
5.2 时间:为什么这个桩不填会握手失败
TLS 要时间有两个用途:一是验证证书有效期,二是参与某些握手消息的计算(会话票据、部分扩展)。如果你开启了MBEDTLS_HAVE_TIME却没提供time()实现,链接会报错;如果你关了它但服务端要求校验证书有效期,握手会在证书验证阶段返回-0x2700(MBEDTLS_ERR_X509_CERT_VERIFY_FAILED)。
做法分两种。正规做法是接一个 RTC 或者从网络协议(比如 SNTP)拿到时间,然后实现mbedtls_platform_set_time()挂进去:
#include "mbedtls/platform_time.h" static mbedtls_time_t port_time(mbedtls_time_t *timer) { mbedtls_time_t now = rtc_get_unix_seconds(); /* 你自己实现的 RTC 读取 */ if (timer != NULL) *timer = now; return now; } void mbedtls_port_time_init(void) { mbedtls_platform_set_time(port_time); }注意 RTC 时间的来源问题:设备出厂时 RTC 不一定是对的,上电后从 1970 年开始走,这时候去验证证书会显示"证书尚未生效"。稳妥做法是首次联网后通过服务端返回的时间校准 RTC,之后再走正常的证书校验流程。
简化做法是直接关掉MBEDTLS_HAVE_TIME和MBEDTLS_HAVE_TIME_DATE,认证模式设为MBEDTLS_SSL_VERIFY_NONE或者只做"公钥比对"。前者完全跳过证书校验,后者把服务端证书的公钥手工烧进固件,握手时只比对公钥是否一致而不看签名链和有效期。第二种在资源紧张的场合非常实用:安全性比完全不校验强得多(能防住大部分中间人),代码量又比完整 X.509 解析少几十 KB。
5.3 网络收发的 BIO 回调:把库和你的通道接起来
这是三个桩里最容易写错的一个。mbedTLS 不关心你底下是 TCP socket、串口上的模组 AT 通道、还是某种私有总线,它只要求你提供两个函数:发数据和收数据。通过mbedtls_ssl_set_bio()注册进去:
static int net_send(void *ctx, const unsigned char *buf, size_t len) { int fd = *(int *)ctx; int ret = tcp_send_raw(fd, buf, (int)len, 5000); if (ret == TCP_ERR_WOULDBLOCK) return MBEDTLS_ERR_SSL_WANT_WRITE; /* 关键:必须原样返回 */ if (ret < 0) return MBEDTLS_ERR_NET_SEND_FAILED; return ret; /* 返回实际发送字节数 */ } static int net_recv(void *ctx, unsigned char *buf, size_t len) { int fd = *(int *)ctx; int ret = tcp_recv_raw(fd, buf, (int)len, 5000); if (ret == TCP_ERR_WOULDBLOCK) return MBEDTLS_ERR_SSL_WANT_READ; if (ret == 0) return MBEDTLS_ERR_SSL_CONN_EOF; /* 对端关闭 */ if (ret < 0) return MBEDTLS_ERR_NET_RECV_FAILED; return ret; }有两个细节必须强调。第一,底层是非阻塞 socket 时,EAGAIN这类情况必须翻译成MBEDTLS_ERR_SSL_WANT_READ或MBEDTLS_ERR_SSL_WANT_WRITE原样返回,绝不能返回 0 或者 -1。返回 0 会被库理解成"对端关闭连接",返回 -1 会被当成致命错误,握手立刻中断。这个坑我在两个项目里都踩过,报错信息指向MBEDTLS_ERR_SSL_CONN_EOF,看着像服务端主动断开,实际是自己代码的问题。
第二,net_send必须返回实际发送的字节数,不能返回 0 表示成功。TCP 的短写(sent 小于 len)在 mbedTLS 里是允许的,库会自己重试剩余部分,但前提是你如实返回已发送的长度。返回 0 会被当成"发不出去",进入 WANT_WRITE 循环,最后超时。
如果你的通道是模组 AT 指令,那net_recv的实现要处理"数据还没到"的状态。AT 模组的+IPD上报是异步的,需要一个环形缓冲区做中转,net_recv从环形缓冲区里取数据,取不到就返回 WANT_READ。这个结构比直接调 socket 复杂,但逻辑是一样的。
5.4 证书与主机名校验的落地配置
握手前的配置顺序有个固定套路,顺序错了会返回-0x7100(MBEDTLS_ERR_SSL_BAD_INPUT_DATA)。正确的顺序是:初始化mbedtls_ssl_context和mbedtls_ssl_config,调用mbedtls_ssl_config_defaults()设置客户端模式,注册 RNG 回调,设置认证模式,挂 CA 证书链,设置主机名,最后mbedtls_ssl_setup()绑定配置。
主机名这一步特别容易漏。mbedtls_ssl_set_hostname()除了给 SNI 用,还参与服务端证书的 CN/SAN 比对。漏了这一步,证书链验证能过,但主机名校验会被跳过,某些服务端会直接拒绝握手。而且这个函数传入的字符串必须在 SSL 上下文销毁前保持有效,不能传一个栈上的临时缓冲区。
CA 证书的嵌入方式也有讲究。PEM 格式需要MBEDTLS_PEM_PARSE_C和MBEDTLS_BASE64_C,还要一个几百字节的解析上下文;DER 格式(二进制)省掉这两块,直接把openssl x509 -outform der出来的字节数组烧进固件即可。资源紧张时坚决用 DER。烧进去之后用mbedtls_x509_crt_parse_der()解析,注意传的是长度而不是 0。
6. 联调阶段:那串负数的错误码怎么读
6.1 从返回码反推问题所在
mbedTLS 所有函数返回负数表示错误,这些负数是十六进制编码的模块号加错误号。第一次看到-0x2700这种东西会懵,其实有规律:高字节是模块标识,低字节是具体错误。下表是我在联调阶段最常撞见的几个:
| 返回码 | 宏名 | 通常的根因 |
|---|---|---|
-0x2700 | MBEDTLS_ERR_X509_CERT_VERIFY_FAILED | 证书链不完整、时间不对、主机名不匹配 |
-0x7100 | MBEDTLS_ERR_SSL_BAD_INPUT_DATA | 配置顺序错、参数为 NULL、未设置主机名 |
-0x7F00 | MBEDTLS_ERR_SSL_ALLOC_FAILED | 堆不够,或者堆已碎片化 |
-0x6900 | MBEDTLS_ERR_SSL_WANT_READ | 不是错误,需要继续读 |
-0x6880 | MBEDTLS_ERR_SSL_WANT_WRITE | 不是错误,需要继续写 |
-0x7880 | MBEDTLS_ERR_SSL_PEER_CLOSE_NOTIFY | 对端正常关闭,属于预期结果 |
-0x7780 | MBEDTLS_ERR_SSL_FATAL_ALERT_MESSAGE | 服务端发了致命告警,通常是套件不匹配或证书被拒 |
看到-0x6900和-0x6880不要当错误处理,它们是"还没准备好,再来一次"的意思,正确做法是把这两个值从握手循环里排除出去,继续调用。很多初学者写了if (ret != 0) { 报错; },结果握手永远卡在第一次调用上。
6.2 打开调试输出定位卡在哪一步
返回码只能告诉你"失败了",告诉不了你"失败在哪一帧"。要看细节,把MBEDTLS_DEBUG_C和MBEDTLS_ERROR_C打开,然后在握手前设置调试级别:
mbedtls_ssl_conf_dbg(&conf, my_debug_print, NULL); mbedtls_debug_set_threshold(4); /* 4 是详细级别,看握手流程够用 */my_debug_print就是一个把字符串往串口打的函数,注意它接收的字符串不带换行,你要自己补。级别调到 4 之后,串口会打印出每一步握手消息的类型、长度、协商出来的套件、证书链的解析过程。
有了这份日志,问题定位就从"猜"变成"看"。典型的现象对照:日志停在=> write client hello之后没有<= read server hello,说明底层发包或者服务端根本没响应;日志显示收到了 server hello 但套件协商失败,说明两边支持的算法交集为空;日志走到证书验证然后报-0x2700,回去查 CA 链和系统时间。
6.3 栈溢出这个隐形杀手
Keil4 工程的启动文件里,Stack_Size默认往往是 0x400(1KB)甚至更小。mbedTLS 的握手路径上局部变量不少,尤其是 RSA 和 ECC 运算,函数调用层级深,1KB 栈根本不够。现象很有迷惑性:程序不报错,直接跑飞,或者进 HardFault,或者表现成某个不相关的全局变量被莫名其妙改写。
处理办法是把启动文件里的Stack_Size改大,客户端单向认证场景我一般设到 0x1000 到 0x2000(4KB 到 8KB)。改完还要确认链接器没有把栈和堆叠在一起、总 RAM 没有超。如果芯片 RAM 确实紧张,另一个思路是把握手过程放在一个专门的任务里跑,给这个任务单独分配大栈,主栈保持小巧。
判断是不是栈问题的快捷方法:在mbedtls_ssl_handshake()调用前后各填一个已知模式的数组,握手返回后检查这个数组有没有被改写。如果被改写了,基本可以确诊栈越界,不用再猜别的。
6.4 内存不够时的三种降级路线
如果实测下来 RAM 就是不够,按这个顺序降级:
第一步,缩小MBEDTLS_SSL_MAX_CONTENT_LEN。从 16384 降到 4096 甚至 2048,能省出 24KB 以上的 RAM。这一步几乎无痛,只要你的应用层单包不超过这个长度。
第二步,关掉 X.509 完整解析,改用预置公钥比对。能省 8 到 15KB Flash 和几 KB RAM,同时省掉一堆堆分配。
第三步,把MBEDTLS_SSL_CONTEXT_SESSION相关特性关掉,减少上下文大小。再不行就只能考虑外挂方案了。
顺带提一句堆的问题。mbedTLS 的很多结构是动态分配的,如果你的工程用的是自己实现的malloc(比如简单的内存池),一定要确认对齐正确、块头开销算够。我遇到过一次握手随机失败,最后发现是自实现的free没有正确合并相邻块,跑了几十次握手之后堆碎片化,mbedtls_calloc返回 NULL,报-0x7F00。改成固定大小的内存池之后问题消失。
7. 跑通之后:性能、加速与量产运维
7.1 握手耗时实测与优化空间
软件实现的 RSA-2048 签名验证在 72MHz 的 Cortex-M3 上大概要 400 到 800 毫秒,ECC secp256r1 的 ECDHE 运算约 200 到 400 毫秒,整个握手过程(含网络往返)通常在 1.5 到 3 秒之间。这个数字对很多应用是可以接受的,但如果你的设备是电池供电、每次上报都要新建连接,那握手开销就很可观了。
优化手段按性价比排序:第一是会话复用,把上次握手协商出来的会话参数缓存下来,下次连接走简化握手,能省掉最贵的非对称运算;第二是保持长连接,别每次发数据都重连;第三才是硬件加速。前两条是架构层面的,改起来麻烦但收益大;硬件加速是锦上添花。
7.2 用芯片自带的加密外设替换软件实现
不少 STM32 型号带 AES 和 HASH 硬件加速外设。mbedTLS 提供了替换接口:定义MBEDTLS_AES_ALT、MBEDTLS_SHA256_ALT等宏,然后自己实现对应的一整套函数(注意是一整套,不能只实现其中几个,头文件里的函数声明一个都不能少)。
实测下来,AES 走硬件能快三到五倍,SHA-256 能快两到三倍。但要注意两点:一是外设初始化、DMA 传输、等待完成的流程会引入额外开销,数据量很小(比如几十字节)的时候硬件反而不如软件快,我测过 32 字节的分组加密,软件实现比"配置外设 + 启动 + 等待 + 读结果"这套流程还快一点;二是替换实现必须和 mbedTLS 的接口语义严格一致,包括上下文结构体的字段、返回值约定,写错一个地方就会得到"握手成功但数据解密全是乱码"这种诡异现象。
有意思的是,RSA 和 ECC 这类非对称运算在 STM32 上通常没有硬件加速,只能靠软件大数运算,这也是握手慢的主要原因。PKA 外设只在部分高性能型号上才有。
7.3 证书过期这件事,量产之后一定会遇到
嵌入式项目里最容易被忽略的运维问题是证书有效期。开发阶段一切正常,设备出货一年半载之后,服务端换了证书,或者根证书到期,几万台设备同时连不上。这类故障排查起来非常痛苦,因为设备端只报一个-0x2700。
几个预防措施值得提前做:服务端证书有效期尽量拉长,或者使用自动化续期机制;设备端在握手失败时把详细错误码和当前 RTC 时间上报到诊断通道,方便远程判断是证书问题还是网络问题;如果采用预置公钥方案,把公钥做成可远程更新的,别烧死在代码里。
还有一个实践:在设备里加一个"降级通道"。TLS 握手连续失败 N 次之后,切到一个受限的明文通道只做固件升级和证书更新,升级完再切回来。这个通道当然要有限制条件(比如只能在特定网段、只能下载签名过的固件),但它能在关键时刻救你一命,避免整批设备变砖。
我个人在这套东西上折腾最久的地方,其实不是配置裁剪也不是移植桩,而是那个-0x6900被当成错误处理导致的"握手永远失败"。当时日志里啥都有,就是看不到失败点,最后把net_recv的返回值一个个打印出来才反应过来。所以如果你现在正好卡在握手阶段,第一件事不是去翻 config.h,而是把 BIO 回调里每一个返回值都打到串口上,看一眼有没有把 WANT_READ 和 WANT_WRITE 给吞掉。这个检查只花五分钟,能省掉两天。