STM32F103上跑mbedtls 2.24.0我第一次把这几个关键词组合在一起的时候以为只要把官方库文件拖进工程、加上几个c文件编译就能过。结果是显而易见的链接器直接报RAM不够。F103这颗Cortex-M3常规型号最多也就是64KB SRAMmbedtls的默认配置几乎是给Linux开发板和PC准备的直接平移到单片机上必然撑爆内存。后来我把配置项一项一项裁剪掉、自己接管内存分配、重写了随机数来源才终于在这颗芯片上把MQTTS握手完整跑通。这篇文章我尽量把线路讲完整为什么要在MCU里做TLS而不是丢给Wi-Fi模块mbedtls 2.24.0该怎么裁剪到F103能启动网络层、随机数、证书校验这些环节怎么适配以及调通之后怎么优化握手时延和内存占用。适合正在准备把MQTT明文接入升级成MQTTS、但手头只有F103和常见网络模块的开发者也适合刚接触mbedtls、对TLS握手流程还比较模糊的入门者。1. 为什么要折腾MQTTS明文MQTT的风险和F103的资源现实1.1 明文MQTT到底有多危险MQTT本身是一个应用层发布订阅协议它不管加密。如果走1883端口裸奔抓包工具里看到的topic、payload、用户名密码全部是明文。有些开发者觉得“我的设备在内网没事”。但实际组网里同一个二层网络里一旦有某个固件有漏洞的设备被控制或者有人接入了一个恶意节点ARP欺骗、DNS劫持、端口镜像这些手段很快就能把MQTT报文捞出来。更别说很多工业场景的设备数据需要经过路由器、云平台转发链路上任何一个环节出问题数据就暴露了。而且MQTT里很多topic是控制指令比如“关闭阀门”“切换模式”“写入阈值”。这种指令被中间人篡改一下造成的影响就不只是数据泄露了而是直接影响设备运行安全。MQTTS本质就是给MQTT套了一层TLS记录层握手时用证书链完成身份鉴别握手后用对称加密保护整个会话内容能同时解决机密性、完整性和一部分身份验证问题。1.2 Wi-Fi模块自带TLS为什么不是长久之计用ESP8266/ESP32这类模组时很多人直接开AT固件里的SSL功能或者用模组SDK里的MQTTS例程。这确实快但有一个问题TLS证书、私钥、握手状态全在模组里MCU拿不到任何信息。一旦服务器证书过期或者要换CA你得重新烧模组固件如果模组被攻击者拿到私钥和证书也一起暴露了。还有一个更实际的问题AT透传加TLS模组内部完成握手后数据虽然加密到服务器了但MCU和模组之间的串口链路依然是明文。如果设备内部有恶意软件或者调试接口被利用照样能截获MCU发给模组的数据。把mbedtls跑在F103里等于把TLS的根握在自己手上。证书放Flash里的独立区域会话上下文由MCU管理重连策略、心跳超时、密钥更新这些都能按自己的逻辑来控制。短期看你只是“麻烦一点”长期看这才是可以维护的方案。1.3 F103的资源账本能跑和跑得顺是两回事F103家族看起来型号很多但对mbedtls来说真正要关注的只有Flash和SRAM。我做了一张表方便你对照手里的芯片芯片型号FlashSRAM主频mbedtls体验STM32F103C8T664KB20KB72MHz非常紧张只能做最小裁剪建议仅验证STM32F103RCT6256KB48KB72MHz比较宽松推荐入门STM32F103ZET6512KB64KB72MHz充裕可以做完整校验和会话恢复mbedtls未裁剪时编译出来的代码量轻松超过200KB FlashRAM峰值也会超过40KB个别场景能到60KB以上这已经超过F103ZET6的SRAM总量了。但把它裁剪到只保留TLS1.2客户端、ECDHE密钥交换、AES-GCM、ECDSA/RSA证书校验之后Flash可以压到50~80KBRAM峰值控制在20KB左右这样在RCT6以上型号上就非常舒适。这个“裁剪”不是可选项而是能不能跑起来的前提。后面我会专门讲配置怎么改。1.4 网络接入方式会直接影响TLS稳定性F103要上网常见有三条路串口AT模组ESP8266、ESP32、4G模组、SPI以太网芯片W5500、MCU自带MAC加外部PHY跑LWIP。mbedtls本身不管底层网络它只要求你提供两个函数一个发送一个接收。但底层网络的质量决定TLS握手是否容易超时、重传是否频繁。AT模组方案实现最轻松MUC只要维护一个串口收发环形缓冲区把mbedtls的send/recv接到串口数据上但AT模组在TCP链路拥塞时会出现背压导致数据积压握手时如果recv超时太短会误报超时。W5500方案吞吐更稳定毕竟SPI接口速度远快于串口且W5500内部有独立ARP/IP/TCP协议栈。LWIP方案最灵活但也最重会吃掉不少Flash和RAM。我的建议是如果只是验证mbedtls流程AT模组够用如果要持久运行选W5500会更省心。本文后面的代码逻辑不依赖具体网络芯片你只要按对应驱动把send/recv函数填充好就行。2. mbedtls 2.24.0的裁剪思路先把配置改到能在F103上启动2.1 为什么选2.24.0而不是3.xmbedtls目前在维护的是2.28 LTS和3.x两条线。2.24.0是2.x系列里一个成熟度还不错的版本网上的教程、Broker兼容性测试、历史资料几乎都以这个版本为参照拿来学习和搭建原型很合适。3.x把很多API改了比如不再支持某些旧版配置宏很多老的例程直接编译不过。在F103这种资源受限平台我更愿意用已经验证过的2.x系列。等你把整套机制吃透再考虑要不要升到2.28 LTSAPI接近迁移成本小。源码获取很简单直接从GitHub下载mbedtls-2.24.0压缩包。进工程时不需要把整个库都拖进去只复制include目录和library目录然后在IDE里按需添加c文件。2.2 用MBEDTLS_CONFIG_FILE接管默认配置mbedtls默认的配置文件是include/mbedtls/config.h里面默认开启了很多功能。最稳妥的做法不是直接改它而是定义编译宏MBEDTLS_CONFIG_FILE指向你自己的配置文件。在工程全局编译选项里加一行#define MBEDTLS_CONFIG_FILE mbedtls_f103_config.h然后在include路径下新建一个mbedtls_f103_config.h内容就是针对F103裁剪后的配置开关。这样即使后面升级mbedtls版本也不会因为改过官方config.h而冲突。建议裁剪后保留这些关键模块平台层MBEDTLS_PLATFORM_C、MBEDTLS_PLATFORM_MEMORY用于自定义内存分配。随机数MBEDTLS_ENTROPY_C、MBEDTLS_CTR_DRBG_C、MBEDTLS_ENTROPY_HARDWARE_ALT。对称加密MBEDTLS_AES_C、MBEDTLS_GCM_C。摘要MBEDTLS_SHA256_CSHA1可以留着但只用于兼容旧证书。证书MBEDTLS_X509_CRT_PARSE_C、MBEDTLS_PK_C、MBEDTLS_PEM_PARSE_C、MBEDTLS_BASE64_C。如果直接用DER证书PEM_PARSE_C可以关掉后面我会说理由。密钥交换MBEDTLS_KEY_EXCHANGE_ECDHE_ECDSA_ENABLED、MBEDTLS_KEY_EXCHANGE_ECDHE_RSA_ENABLED按你的Broker证书类型保留一个即可。公钥算法MBEDTLS_ECDSA_C、MBEDTLS_ECP_C、MBEDTLS_ECP_DP_SECP256R1_ENABLED、MBEDTLS_RSA_C。协议框架MBEDTLS_SSL_TLS_C、MBEDTLS_SSL_CLI_C、MBEDTLS_SSL_PROTO_TLS1_2。调试MBEDTLS_DEBUG_C只在调试阶段开跑稳定后关掉。要关闭的功能就多了MBEDTLS_CAMELLIA_C、MBEDTLS_ARIA_C、MBEDTLS_DES_C、MBEDTLS_RC4_C、MBEDTLS_MD4_C、MBEDTLS_MD5_C、MBEDTLS_XTEA_C、MBEDTLS_BLOWFISH_C以及服务端才需要的MBEDTLS_SSL_SRV_C、MBEDTLS_SSL_TICKET_C等。其中MBEDTLS_SSL_IN_CONTENT_LEN和MBEDTLS_SSL_OUT_CONTENT_LEN这两个参数默认是16384对应16KB的TLS记录缓冲这在F103上是灾难要改成4096。2.3 裁剪后的体积实测我在STM32F103RCT6上做过一组对比。默认配置全开编译出来的固件超过230KBRAM更是爆掉裁剪后编译出来的mbedtls相关代码约55KB全局静态RAM约1.5KB堆内存给到16KB就能跑通完整握手。注意这个体积是在-O2优化级别下的结果如果用-O0会显著变大建议把mbedtls的源文件单独设置为-O2。这里有一个容易忽略的点mbedtls的库代码量和你的config.h强相关但有些模块不是你关了它就不会编译链接器只看有没有被引用。比如ECP_C引用了椭圆曲线相关代码如果你保留了secp256r1之外还保留了很多曲线代码量会无故增大。我的配置里只留MBEDTLS_ECP_DP_SECP256R1_ENABLED一条曲线足够应对绝大多数Broker。2.4 堆内存规划给mbedtls一块专属区域mbedtls在握手过程中会频繁申请释放内存证书解析、密钥协商、TLS记录缓冲全在堆上。F103裸机环境用标准库的malloc也能跑但碎片问题在长期运行中很麻烦。我建议直接给它划一块独立的静态内存池然后在main函数里注册自定义alloc/free函数。#define MBEDTLS_MEM_POOL_SIZE 16 * 1024 static unsigned char mbedtls_pool[MBEDTLS_MEM_POOL_SIZE] __attribute__((aligned(8))); static void *mbedtls_platform_calloc(size_t n, size_t size) { // 在这里实现简单的内存池分配 } static void mbedtls_platform_free(void *ptr) { // 在这里实现内存池释放 } // main函数里调用 mbedtls_platform_set_calloc_free(mbedtls_platform_calloc, mbedtls_platform_free);内存池大小怎么定先给16KB试跑同时监控mbedtls_ssl_handshake返回前后堆的占用差值。如果差值是12KB左右说明你的裁剪下握手峰值约12KB留点余量给MQTT报文即可。量化的方法是在自定义calloc里记录alloc_count和peak_used还可以在握手后打印出来。3. 移植到F103的关键步骤网络适配、随机数与握手调用链3.1 网络层适配把mbedtls的send/recv接到你的网卡上mbedtls在传输层只要求两个函数指针原型如下int net_send(void *ctx, const unsigned char *buf, size_t len); int net_recv(void *ctx, unsigned char *buf, size_t len);如果你是AT模组ctx可以是一个包含串口句柄或AT状态的结构体。发送函数直接往串口写接收函数从环形缓冲取数据。但要注意AT模组在透传模式下如果TCP窗口满串口数据会发不出去此时如果net_send一直阻塞等待TLS握手流程就会被卡死。正确做法是设置一个发送超时比如连续等待100ms仍发不出去就返回超时错误回传给mbedtls让它决定是否重试。如果你用W5500实现就更直接int w5500_send(void *ctx, const unsigned char *buf, size_t len) { return wiz_send_socket(sock, buf, len); } int w5500_recv(void *ctx, unsigned char *buf, size_t len) { return wiz_recv_socket(sock, buf, len); }然后通过mbedtls_ssl_set_bio注册这两个函数mbedtls_ssl_set_bio(ssl, sock_ctx, w5500_send, w5500_recv, NULL);第四个参数是接收超时回调如果你不想让recv无限等待可以在这里返回一个超时周期mbedtls会利用它控制重传和WANT_READ状态机。3.2 随机数种子F103的熵从哪来F103没有硬件随机数发生器这是移植mbedtls时最容易忽略的坑。mbedtls默认会调用mbedtls_hardware_poll来收集熵如果这个函数没实现或者返回的“随机数据”很弱TLS握手在生成临时密钥时就会出问题甚至直接报错。标准做法是组合多个熵源。我的实现里用了四路STM32芯片唯一IDUID做基础标识提供唯一性但不是随机性。内部RTC计数器的低16位提供时间变化信息。ADC采集内部温度传感器的噪声多次采样取低字节。外部GPIO引脚上的随机电平变化比如按键按下时间间隔。把这几路数据通过SHA256做一次混合输出给mbedtls_ctr_drbg_seed作为初始种子。这里有一个经验不要在开机后马上做TLS握手先让系统跑几百毫秒让ADC采样足够多次再开始种子初始化熵质量会好很多。时间函数也要替换。mbedtls校验证书有效期时会调用time()F103裸机没有标准时间库需要自己提供mbedtls_platform_set_time(my_rtc_time_get);my_rtc_time_get返回Unix时间戳。如果没有外接RTC可以在编译期写死一个时间但半年后证书就验证过期了。更好的办法是上电后通过MQTT或者NTP校时再把校时结果写入内部RTC。3.3 客户端握手调用链每个API都在干什么跑通一次TLS握手调用顺序大概是这样的mbedtls_net_init(server_fd); mbedtls_ssl_init(ssl); mbedtls_ssl_config_init(conf); mbedtls_x509_crt_init(cacert); mbedtls_ctr_drbg_init(ctr_drbg); // 连接TCP端口MQTTS默认8883 mbedtls_net_connect(server_fd, host, port, MBEDTLS_NET_PROTO_TCP); // 配置TLS客户端参数 mbedtls_ssl_config_defaults(conf, MBEDTLS_SSL_IS_CLIENT, MBEDTLS_SSL_TRANSPORT_STREAM, MBEDTLS_SSL_PRESET_DEFAULT); mbedtls_ssl_conf_authmode(conf, MBEDTLS_SSL_VERIFY_REQUIRED); mbedtls_ssl_conf_ca_chain(conf, cacert, NULL); mbedtls_ssl_conf_rng(conf, mbedtls_ctr_drbg_random, ctr_drbg); mbedtls_ssl_setup(ssl, conf); // 设置域名用于SNI和证书域名校验 mbedtls_ssl_set_hostname(ssl, host); mbedtls_ssl_set_bio(ssl, server_fd, net_send, net_recv, NULL); while ((ret mbedtls_ssl_handshake(ssl)) ! 0) { if (ret ! MBEDTLS_ERR_SSL_WANT_READ ret ! MBEDTLS_ERR_SSL_WANT_WRITE) { // 错误处理 break; } }握手成功之后mbedtls_ssl_write和mbedtls_ssl_read就等价于加密后的MQTT收发通道。需要注意的是mbedtls_ssl_set_hostname不是可选项。如果你直接忽略它部分Broker会因为没有SNI而返回握手失败而且证书的域名校验也会失败。关于VERIFY_REQUIRED调试阶段你可以用VERIFY_NONE先跳过证书验证快速验证整个链路但正式产品必须打开。否则中间人攻击直接伪造一个服务器就能接管你的设备。3.4 单任务与重连为什么不要在中断里调TLSF103裸机环境我建议把整个TLS流程放进主循环严格串行执行“连接TCP、TLS握手、MQTT收发、关闭”。不要在定时器中断或者UART中断里直接调用mbedtls函数因为mbedtls内部有很多大结构体中断嵌套会导致栈溢出并且难排查。如果用了FreeRTOS可以单独开一个任务负责TLS连接任务栈要舍得给我实测X509证书解析和握手过程栈峰值可以到1.5KB左右任务栈给到3KB以上才安心。还有一个关键点多个线程不要共享同一个mbedtls_ssl_context每个连接都应该是独立的context。F103资源有限通常同时只有一个MQTTS连接所以单任务模型最合适。断线重连时先调用mbedtls_ssl_session_reset重置会话上下文再重新走一遍握手流程。不要反复init/free同一个context那会造成内存碎片。4. 调通之后继续挖性能握手时延、会话恢复与RAM占用4.1 完整握手到底要多久我在F103RCT6上以72MHz主频、W5500网卡、EMQX Broker搭了一套环境用MBEDTLS_TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256套件完整握手大约耗时2.8秒到3.5秒。这个时间大部分花在ECDHE临时密钥生成和证书链公钥运算上网络延迟只是小头。换用PSK预共享密钥套件握手时间能压到0.8秒左右但PSK方案对Broker不友好因为你得在服务器里为每台设备配置共享密钥管理成本高。另一种思路是让F103支持TLS会话恢复这个下面讲。4.2 TLS会话恢复让断线重连从秒级降到毫秒级MQTT本身是长连接心跳通常几十秒一次TLS握手只在首次连接时发生。但设备重启、网络波动、Broker重启都会触发新的TCP连接如果每次都做完整TLS握手用户会明显感觉到“设备上线慢”。mbedtls 2.24.0支持session resumption。客户端在第一次握手成功后调用mbedtls_ssl_get_session把session保存到RAM里下次重连时先mbedtls_ssl_set_session然后照常握手。如果Broker同意恢复整个握手过程就变成一次简单的ticket交换耗时从3秒降到几十毫秒。一个实际经验保存session只对短期断线有意义session ticket有过期时间Broker可配置常见是10分钟到2小时。超过有效期后还是要做完整握手所以你的重连逻辑不能只依赖session恢复必须兼容完整握手的场景。4.3 DER证书比PEM更适合F103mbedtls解析PEM证书时需要先把base64文本解码成DER数据这个过程会产生额外RAM分配。如果你把CA证书和服务器证书用OpenSSL转成DER格式再用mbedtls_x509_crt_parse直接解析DER数组RAM占用能少掉1~2KB而且解析速度更快。转换命令很简单openssl x509 -in ca.pem -outform DER -out ca.der然后将ca.der用xxd -i生成C语言数组放到const区const unsigned char ca_der[] { 0x30, 0x82, ... }; mbedtls_x509_crt_parse(cacert, ca_der, sizeof(ca_der));注意证书数组放在Flash里别放到栈上。用xxd -i生成的数组默认是unsigned char xxx[]你要手动加const。4.4 把TLS记录缓冲从16KB降到4KBmbedtls 2.24.0里MBEDTLS_SSL_IN_CONTENT_LEN和MBEDTLS_SSL_OUT_CONTENT_LEN默认都是16384字节对应TLS1.2最大记录长度16KB。F103如果保留这个默认值光两个缓冲就吃掉32KB SRAM根本没法用。我把它改成4096字节。这意味着TLS单条记录最多承载4KB密文MQTT的CONNECT、SUBSCRIBE、PUBLISH报文如果不超过4KB完全没影响。但如果你的PUBLISH payload比较大比如上传几千字节的传感器数据就可能触发MBEDTLS_ERR_SSL_BUFFER_TOO_SMALL。解决办法是在应用层自己分片每次mbedtls_ssl_write写入不超过3.5KB明文保证加密后不超4KB。改成4096之后加上X509解析、密钥协商、栈空间整个握手的RAM峰值大约在16KB左右。如果你的F103只有20KB SRAM这个方案也勉强能跑但建议至少选RCT6。5. 实战中踩过的坑与故障排查链路5.1 错误码-0x7780证书链验证失败根因往往不在算法我第一次跑握手卡在-0x7780对应的错误是MBEDTLS_ERR_X509_CERT_VERIFY_FAILED。这个错误信息很笼统我当时第一反应是证书算法不匹配后来排查下来是三个原因叠加。第一个原因是导入的CA证书是错的。我原本要从Broker的ca.pem导入根证书结果不小心把服务器证书当成根证书导进去了。第二步是RTC时间没有校时证书过期校验直接把当前时间当成1970年任何证书都会被判定为过期。最后是域名校验失败mbedtls_ssl_set_hostname没设置证书链即使合法也匹配不上。排查时打开调试输出最关键mbedtls_debug_set_threshold(4);它会打印TLS握手各阶段的详细信息包括证书链结构、校验步骤、失败位置。强烈建议调试阶段保留MBEDTLS_DEBUG_C上线时再关掉。5.2 HardFault直指栈溢出F103跑mbedtls最典型的问题就是HardFault。我第一次遇到时把错误定位到ecp.c里的某条椭圆曲线运算以为是算法问题后来查了汇编栈指针才发现是栈不够。裸机环境下main函数的栈由启动文件里的Stack_Size决定。如果你默认设了0x4001KBTLS握手过程中mbedtls会反复调用ECDHE运算和证书解析栈很容易溢出。改成0x10004KB一般就稳了。如果用了FreeRTOS任务栈同样要给足。另外一个技巧在HardFault_Handler里读取当前栈指针对照链接脚本里的栈顶地址能快速确认是不是栈溢出。不要凭感觉瞎猜。5.3 网络超时不一致导致Broker踢人MQTT层有自己的心跳机制TLS层也有自己的超时控制。我遇到过一次很诡异的现象TLS握手成功后MQTT能发几条消息几分钟后Broker就把连接断开了设备反复重连。排查后发现问题出在net_recv实现上。我的网络接收函数在没有数据时立即返回0mbedtls把它当成底层异常直接终止了TLS会话。Broker那边看到连接异常断开自然也就把会话清了。正确做法是在net_recv里实现超时等待。当没有数据时等待一段时间比如50ms再返回MBEDTLS_ERR_SSL_WANT_READ让mbedtls知道这是暂时没数据而不是连接断了。这样MQTT的心跳包才能正常收发。5.4 中断里调用TLS导致死锁我把串口接收中断的数据直接往mbedtls接收缓冲区里写结果发生了一次死锁。原因很微妙中断触发时主循环可能正好在执行mbedtls的握手状态机也在操作同一个缓冲区两边同时修改指针就乱了再严重一点就直接HardFault。这个问题的解决思路不是加锁而是避免在中断上下文触碰TLS数据。中断只负责往环形缓冲区放原始字节主循环里的net_recv从环形缓冲区取数据再喂给mbedtls。这样可以保证mbedtls的所有API调用都发生在非中断上下文状态一致。5.5 裁剪没生效MAP文件不会说谎有时候你觉得已经关掉了某些模块编译出来的固件还是很大。这时候不要去猜直接看链接生成的MAP文件搜索对应的模块符号比如mbedtls_camellia_*或者mbedtls_arc4_*如果符号出现在Flash区域说明配置宏没有真正传到编译单元里。常见原因有两个。一是在IDE的全局预处理器里定义了MBEDTLS_CONFIG_FILE但写成了MBEDTLS_CONFIG_FILEmbedtls_f103_config.h导致头文件搜索不到。二是config.h文件里某些宏存在循环依赖比如你先定义了MBEDTLS_SSL_TLS_C后面又定义了#undef MBEDTLS_SSL_CLI_C最终状态不是你预期的那样。还有一个更隐蔽的坑mbedtls 2.24.0默认会在config.h末尾包含check_config.h检查配置一致性。如果你把MBEDTLS_PKCS1_V21关了但证书签名还需要RSA-PSS编译时会在check_config阶段直接报错。这个报错信息有时候会误导你“功能没关对”实际上是你关少了而不是关多了。6. 面向长期维护的移植后建议6.1 把证书与密钥独立到Flash分区裁剪配置稳定之后证书数据最好不要和其他业务代码混在一起烧录。尤其是设备里要保存客户端私钥的场景别人用调试器读Flash就能把私钥dump出来。可以单独划一个Flash扇区存放证书和密钥固件升级时不动这个分区需要更换证书时也只需要写Flash而不用重刷整个固件。6.2 日志分级与远程排查mbedtls的mbedtls_debug_set_threshold可以控制日志等级但默认是输出到stdout。在F103上没有stdout你需要通过mbedtls_ssl_conf_dbg注册自己的日志回调把日志打到UART或者存储到环形日志里。正式产品上建议保留等级1错误级的日志握手失败时能快速定位是证书问题还是网络问题。6.3 后续升级路线2.24.0跑通之后如果项目要量产强烈建议换成2.28.x LTS版本API基本兼容安全补丁和Bug修复持续维护。如果未来换到更高性能的芯片比如STM32H7或者带硬件加密加速的系列再考虑迁移到mbedtls 3.x那时底层TLS机制你已经完全理解迁移只是API层面的事。6.4 一个小技巧用MAP文件监控堆水位我最后习惯性在每次编译后看一眼MAP文件里的堆栈使用情况。在自定义calloc里保留一个全局变量记录峰值分配字节数每次MQTT断线重连后都对比一下数值如果发现峰值逐渐逼近内存池上限就该优化裁剪配置或者增加内存池大小。这个习惯帮我提前发现了好几次内存碎片问题比等到设备运行几个月后再莫名其妙死机要省心得多。在这个项目里我踩过最深的坑还不是技术本身而是“默认配置能跑”这个错误预期。mbedtls对Linux是开箱即用对单片机则是开箱即爆。只要你在移植之前先接受“F103必须裁剪”这个前提后续的路就会顺畅很多。