简介:面向瑞萨、ST及上海东软等常见MCU平台,这套MD5加密源码为单片机开发者提供了可直接移植的哈希算法C实现,适用于数据完整性校验、密码存储与防篡改等嵌入式安全场景。压缩包仅含1个C源文件和1个头文件,整体约3KB,结构极简;C文件包含初始化、分组处理、填充及摘要生成等完整流程,头文件则定义相关结构体与函数原型,便于快速集成到现有工程。实现覆盖MD5核心四步:寄存器初始化、64轮迭代运算、消息填充与长度附加、128位摘要输出,并针对单片机内存与算力受限的特点做了精简处理,可降低资源占用;实际使用时还需根据具体芯片的编译器与指令集进行少量适配。已有805人学习、下载,适合具备基础C语言和单片机开发经验的读者直接用于项目改造或参考学习。
1. 单片机MD5加密:名字叫加密,实际做校验,落地前先想清楚RAM和时序
"单片机MD5加密"这个词在很多嵌入式需求文档里反复出现,但它在算法分类上和AES、RSA完全不是一个阵营:MD5是散列摘要,不是可逆加密,它的本职工作是给一段数据算一个固定长度的"指纹",用来确认数据有没有变化。做设备接入、固件升级、参数校验的人需要这个功能,但同时也是最容易踩坑的,因为MD5对单片机并不算一个"调好库就能跑"的黑匣子,资源的分配、字节序、分片更新、中断重入都会影响最终结果。这篇文章会从原理、源码选型、工程接入到避坑排查走一遍,让新手能照着把代码跑通,让熟手也检查一下自己容易忽略的几个边界。
2. MD5原理与单片机资源适配:先想清楚算的是什么,再评估跑不跑得动
2.1 MD5算法结构:填充、分块、四轮循环与状态变量
MD5处理任意长度输入时,先把数据分成512位的块。最后一个块需要填充:先补一个二进制1,再补一堆0,直到剩下64位用来放原始消息总长度,长度单位是位,并且按小端存储。这个填充动作通常由MD5_Init、MD5_Update执行过程中自动完成,到MD5_Final才会补齐最后一块。理解它有助于排查边界错误,尤其是处理0字节、63字节、64字节、65字节这类临界长度时容易出现反常识的结果。
每个512位块会被拆成16个32位小端字。算法维护四个32位状态变量A、B、C、D,初始值固定为0x67452301、0xefcdab89、0x98badcfe、0x10325476。每个块要做四轮处理,每轮16步,总共64步。四轮对应的逻辑函数分别是F、G、H、I,全部由位运算组成:
F = (B & C) | (~B & D)
G = (B & D) | (C & ~D)
H = B ^ C ^ D
I = C ^ (B | ~D)
每次循环还要加上输入字、常数T[i]和当前状态,然后做一次循环左移。常数T[i]是一张64项的固定表,由正弦函数生成,在源码里基本以常量数组形式出现。
这里需要把概念纠正过来:MD5是消息摘要算法,没有密钥,正向计算很快,反向基本不可行,但它存在碰撞风险。所以需求文档里写"MD5加密"时,实际要的是完整性校验:确认数据没有被改,而不是把数据内容藏起来。如果需求写的是"加密通信保密",应该去选AES;如果需求是"固件校验、下载包完整性",MD5才上场。这个判断做错了,后面整个设计都会跑偏。
MD5对单片机友好的一点是它没有字符串、没有动态分配、也不依赖外部硬件。输入数据在缓冲区里凑满64字节就压缩一个块,剩下不足64字节的留到下一次。因此算法天然适合分片处理,这正好符合嵌入式系统"数据一包一包来"的常态。
2.2 单片机资源盘点:RAM、ROM和耗时怎么估算
一个经典C实现的MD5上下文结构体包含四个状态变量(16字节)、64字节消息缓冲区、8字节长度计数,再算上函数内部的临时变量,整体占用约90到100字节RAM。如果使用全局上下文,它固定在静态区,不受栈深度影响;如果定义在函数栈上,则需要额外关注任务栈大小。
ROM占用主要由代码段和常量表组成。代码段是算法核心和三个接口函数,通常在2KB左右;T常数表固定占用256字节。整体ROM增量在3KB到6KB之间。芯片Flash只有8KB的时候要仔细裁剪,Flash在64KB以上基本不用纠结。
耗时估算有两种办法。第一种按算法步数粗算:每个64字节块要做64步,每步一次加法、一次移位、一次逻辑运算,在常见32位单片机上,每步大约几十个周期;每KB数据约等于16个数据块。第二种是实测量:把需要计算的500字节数据放到一个函数里,翻转一个GPIO,用逻辑分析仪或示波器量耗时。我做项目时偏向后者,因为编译器优化、总线等待、Flash读取速度都会带来偏差,理论算出来的数字只够用来判断"是不是该放到后台任务去跑"。
低功耗场景要考虑得更细。如果单片机长期跑在低频模式,唤醒后连续计算大报文,电流尖峰和总耗电都会变高。建议把MD5计算放在刚唤醒、准备进入睡眠前的时间段,不要在中断里分多次执行。
2.3 源码选型:适合单片机的MD5实现应有这几个特征
网上能找到的MD5源代码很多,但不少是给PC环境写的,直接搬进单片机工程会带出一堆问题。我选单片机MD5源码时先做四件事。
第一,查依赖。源码是不是只用标准C的整数类型和内存操作,有没有用到文件读写、动态内存、printf。出现fopen、malloc、FILE这类符号,说明源码和嵌入式环境不匹配。正确的做法是输入数据通过接口传入,算法内部不碰文件系统。
第二,查上下文。所有中间状态,包括state、count、buffer,都应该封装在一个结构体里。这样一个计算流程只依赖一个上下文对象,可以被复制、暂停、恢复。如果源码使用全局state,两个独立的数据流同时计算就会互相覆盖,这种源码在嵌入式项目里基本不可用。
第三,查接口。必须提供类似MD5_Update(ctx, data, len)的分片更新接口,允许传一段数据算一部分,再传下一段继续算。没有分片接口的源码,要求调用方一次性把完整数据传进来,这在单片机里往往不现实,因为没有哪个设备会为了算校验把整个固件镜像搬进内存。
第四,查可配置性。源码是否能在-O2优化下编译,常量表是否可以放到只读数据段,是否用了变长数组或递归。变长数组和递归在小栈单片机上都有隐患。尽量选纯函数式、可重入的代码,以后调试和移植都省事。
如果找不到满意的源码,自己按标准实现一个精简版也不难。后面一章会给一个最小接口和典型接入流程,照着改通常比修一份陌生的PC源码快得多。
2.4 先用固定测试向量自检,再进业务代码
不管从哪份源码开始移植,第一步都不是接业务代码,而是用标准测试向量验证算法本身。MD5最常用的测试向量是"abc"字符串,标准摘要为900150983cd24fb0d6963f7d28e17f72。空字符串的摘要为d41d8cd98f00b204e9800998ecf8427e。这两个够用,前者覆盖非空数据,后者覆盖长度为0的边界。
在单片机工程里写一个临时自检函数,完整代码如下:
/* 自检函数:计算"abc"的MD5,与标准摘要比对 */ uint8_t test_input[] = "abc"; uint8_t expect[16] = { 0x90, 0x01, 0x50, 0x98, 0x3c, 0xd2, 0x4f, 0xb0, 0xd6, 0x96, 0x3f, 0x7d, 0x28, 0xe1, 0x7f, 0x72 }; uint8_t digest[16]; MD5_CTX ctx; MD5_Init(&ctx); MD5_Update(&ctx, test_input, 3); MD5_Final(digest, &ctx); if (memcmp(digest, expect, 16) == 0) { /* 算法正确 */ } else { /* 移植有误,立即排查 */ }这里最需要注意的是test_input数组用3而不是sizeof(test_input),因为sizeof会包含结尾的'\0',计算结果就会和标准摘要不一致。很多新手第一次自检就在这个细节上翻车。
我还有个血泪经验:第一次移植时,在PC上调通了,烧到板子上却发现只有"abc"对得上,长一点的报文全错。原因不是算法本身,而是输入缓冲区没有做内存对齐,32位指针直接访问了未对齐地址,在常见32位内核上触发硬件异常。后来我改成用memcpy把每4字节拷到一个临时uint32变量再参与运算,问题消失。所以自检阶段不仅要看摘要对不对,还要故意测几种长度:0字节、1字节、63字节、64字节、65字节、512字节。这些边界值能一次性暴露填充、长度进位和缓冲区拼接的手是问题。
3. 移植MD5源代码:最小接口、分片调用和内存优化
3.1 头文件与上下文结构体:先建立一个不依赖平台的数据类型
MD5不在标准库里,所以头文件需要自己定义。下面是一个通用性很强的md5.h,我习惯在写业务代码之前就把这个文件定好。
// md5.h #ifndef MD5_H #define MD5_H #include <stdint.h> typedef struct { uint32_t state[4]; /* 当前摘要状态 A, B, C, D */ uint32_t count[2]; /* 已处理的字节数,2个32位拼接成64位 */ uint8_t buffer[64]; /* 等待被压缩的输入块缓冲区 */ } MD5_CTX; /* 初始化上下文 */ void MD5_Init(MD5_CTX *ctx); /* 向正在计算的数据流中追加数据,支持多次调用 */ void MD5_Update(MD5_CTX *ctx, const uint8_t *data, uint32_t len); /* 完成处理,输出16字节完整摘要 */ void MD5_Final(uint8_t digest[16], MD5_CTX *ctx); #endif这里所有类型都用uint8_t、uint32_t,保证在8位和32位单片机上宽度一致。count数组用两个32位,是因为64位整数在8位机上处理很慢,拆成两个32位后,每次更新只需要处理低32位溢出时进位到高32位。buffer固定为64字节,正好一个512位块。MD5_Update每次调用会先把数据拷贝进buffer,凑满64字节就压缩一次,剩下不足64字节的留在buffer里,等MD5_Final再填充和压缩。
3.2 一次性计算和分片计算:两个调用模型
对单片机开发者来说,最常用的调用有两种。第一种是短数据一次性算完,例如校验一条命令、一个结构体:
/* 计算一个短结构体的MD5摘要 */ uint8_t md5_result[16]; MD5_CTX ctx; uint8_t payload[] = {'o', 'p', 'e', 'n', 0x00, 0x01}; MD5_Init(&ctx); MD5_Update(&ctx, payload, sizeof(payload)); MD5_Final(md5_result, &ctx);payload里含有一个0x00字节,如果用strlen就会提前截断。这里必须用sizeof(payload)得到6字节。MD5_Final拿到的是16字节二进制摘要,不是十六进制字符串,需要打印时再自己做转换。这个逻辑是,只要参与计算的字节序列和PC端完全一致,摘要就一定一致。
第二种是长报文分片计算。固件升级时,整个镜像可能几兆字节,不能一次性搬到内存,而是一边从Flash或通信接口读数据,一边算。示例:
MD5_CTX ctx; uint8_t digest[16]; uint8_t chunk[512]; uint32_t total_len; uint32_t offset = 0; MD5_Init(&ctx); while (offset < total_len) { uint32_t read_len = (total_len - offset > sizeof(chunk)) ? sizeof(chunk) : (total_len - offset); read_data_from_flash(chunk, offset, read_len); /* 项目里的读取函数 */ MD5_Update(&ctx, chunk, read_len); offset += read_len; } MD5_Final(digest, &ctx);每次分片长度可以从16字节到2KB不等,MD5_Update内部会处理跨块边界。分片太小会导致函数调用过多,分片太大则浪费调用方缓冲区。我一般选512字节,刚好和常见Flash页大小或通信缓冲区对齐。这里注意read_data_from_flash只是一个示意,实际工程里要替换成你项目里的读取函数,并保证每次读取的地址和长度正确。
3.3 核心实现里的三个关键参数:初始值、移位位表、T常数
如果你拿到的是一份标准C实现,核心代码里会有四段,对应四个逻辑函数。每段里都有三个关键参数,移植时任何一个改错,摘要都会错。
第一是状态初始值。A=0x67452301,B=0xefcdab89,C=0x98badcfe,D=0x10325476。它们写在MD5_Init里。注意这些32位常量在内存中的小端存储顺序,用调试器看内存时字节顺序可能和书写相反,这是正常的。
第二是循环左移位数。标准实现里每一位的移位量来自一个固定序列,例如第一轮用7、12、17、22循环,第二轮用5、9、14、20循环。如果源码里的S数组或switch分支数字改错,所有摘要都会错。
第三是T常数表。标准T[i]等于4294967296乘以abs(sin(i))的整数部分,i从1到64。源码里通常会直接给出64个uint32_t常量,例如T[1]=0xd76aa478,T[17]=0xf61e2562。对照标准表逐项核对,是移植后自查最有效的手段。
每步运算的核心是类似A = B + rotate_left(A + F(B,C,D) + X[k] + T[i], s)的表达式。这里的A、B、C、D、X[k]都必须是uint32_t。如果在代码里为了省事用了unsigned int,而编译器把unsigned int当16位处理,结果就会出错。这种问题在8位单片机上尤其容易发生,所以我在关键运算处显式使用uint32_t,不用平台相关的默认类型。
3.4 内存优化:上下文放全局、临时缓冲去掉、常量放只读区
小RAM单片机上主要有三个优化手法,保证MD5跑得动。
第一,把MD5_CTX定义成全局变量,而不是定义在函数栈上。全局变量占用静态RAM,但大小固定,不会造成栈溢出。例如按下面这种方式封装:
/* 全局上下文,避免栈空间被大量占用 */ static MD5_CTX g_md5_ctx; int md5_calc(const uint8_t *data, uint32_t len, uint8_t digest[16]) { if (data == 0 || digest == 0) { return -1; } MD5_Init(&g_md5_ctx); MD5_Update(&g_md5_ctx, data, len); MD5_Final(digest, &g_md5_ctx); return 0; }这种写法有一个前提:同一时间内只能有一个计算流程使用这个全局ctx。如果系统里多个任务会同时算摘要,每个任务必须有自己的ctx,或者用互斥锁包住整个计算过程。否则两个任务交替调用,缓冲区互相盖,摘要必然错。
第二,减少临时缓冲。如果数据已经在DMA缓冲区里,直接把指针传给MD5_Update,不要再拷贝到另一个"整齐"数组。MD5_Update内部本来就要拷贝到自己64字节buffer,调用方再拷贝一次就是浪费。
第三,把常量表放到只读数据段。C语言里用const uint32_t T[64]定义,编译器通常会把常量放到Flash的只读区。如果编译器还是把它放到RAM,需要检查链接脚本,把该段定位到Flash的.rodata区域。由于单片机执行代码时一般可以只读访问Flash,这样MD5模块的RAM占用就只有全局上下文加少量局部变量,几乎没有额外开销。
4. 单片机MD5常见问题与避坑排查:从摘要不对到系统复位
4.1 现象一:摘要结果和PC端不一致,短数据能对上,长数据对不上
现象:算"abc"这类短字符串,结果和标准摘要一致;换成几百字节的数组,结果就开始变,甚至每次复位结果都不一样。
原因:最常见的是长度参数传错。strlen遇到字符串中间的0x00会提前截断,而MD5要求计算所有字节;另一种是把sizeof(指针)当作数组长度,结果只算了4或8字节。还有一种是数据源本身有符号扩展问题,把int8_t负值传给uint8_t参数,字节内容被改动。
解决:核对所有调用MD5_Update的地方,确认长度是从接口层得到的字节计数,而不是从字符串函数推断。测试时故意构造一个包含0x00、0xFF的配置块,和PC上对同一份二进制文件计算的结果比对。如果还不一致,在MD5_Final前把原始输入字节通过串口回传,在PC端人工比较是数据传错还是算法算错。
4.2 现象二:在中断服务函数里调用MD5_Update,系统卡死或行为异常
现象:在UART接收中断或定时器中断里直接调用MD5_Update,中断处理时间变长,主循环响应迟钝,严重时看门狗复位。
原因:MD5_Update不是一条指令级别的短计算。在常见32位单片机上,一个块压缩要执行几百条指令,在8位机上可能上千条。中断里做这种耗时操作会拉高系统响应延迟,如果中断优先级更高,还会压掉其他更紧急的中断处理。
解决:把MD5计算从ISR里挪出来。常见做法是ISR只把接收到的数据放进环形缓冲区,置一个事件标志;主循环或专用任务发现标志后,调用MD5_Update。如果一定要在中断上下文算,需要先关掉所有可被抢占的中断,保证计算期间不被其他ISR打断,但这是牺牲实时性换来的,我只建议在计算量很小的场景使用。
4.3 现象三:RAM不足或栈溢出,MD5跑着跑着数据被踩
现象:初始化MD5时一切正常,跑几次摘要后某个全局变量无故改变,或者调用MD5_Final时进入硬件异常。
原因:经典MD5实现里,MD5_Update内部会声明一个16个uint32_t的数组,用于存放当前块的16个输入字,这就要64字节本地变量。再加上函数嵌套调用,一次MD5_Update在栈上可能需要100字节以上。如果任务栈只分配了256字节,就很容易溢出。
解决:第一步给MD5调用所在的任务加栈,至少增加256到512字节;第二步把上下文结构体和输入字数组改成静态或全局,减少栈压力。如果已经用全局ctx,直接看任务栈剩余空间。编译器开了-fstack-usage后,会生成每个函数的栈使用量分析,可以借此找到最大栈开销的函数,比盲猜快很多。
4.4 现象四:MD5计算耗时太长,实时任务出现周期性抖动
现象:项目主循环周期原本是几毫秒,加入MD5后变成几十毫秒,或者某个周期性任务抖动明显。
原因:每次MD5_Update调用都要做数据搬运和可能的块压缩。如果每收到1字节就调一次MD5_Update,函数调用开销和循环开销会占主导,反而比攒够64字节一次计算慢得多。
解决:调整调用策略。在采集线程里先把数据累积到自己的缓冲区,攒到至少512字节再一次性调用MD5_Update。如果业务上必须边收边算,也要保证每次调用至少传入几十字节。另一个处理是降低计算频率,如果只是偶发货校验用,完全没必要每周期都算。
4.5 现象五:用MD5做防篡改,结果数据被改了也验不出来
现象:固件被恶意替换后,MD5摘要竟然和原始固件一致,校验流程放行了。
原因:MD5碰撞攻击在业界已经很成熟。攻击者可以构造一个碰撞块插到数据前面,让整个文件的MD5摘要和原版一致。对量产设备升级来说,单靠MD5容易被绕过。
解决:在MD5基础上加盐。把一段设备固定的密钥和固件内容混合后再计算,相当于一个简化版HMAC-MD5,攻击者不知道盐值就无法预构造碰撞。更稳妥的方案是换用SHA-256或国产SM3,主流32位单片机上计算速度也可接受。如果项目限制必须用MD5,至少要加长盐值和版本号,并配合签名算法做固件认证。
5. 进阶用法:用MD5给固件升级加一道带盐的完整性校验
有了前面的基础,最后给一个实用方案:在bootloader里用MD5校验固件,并且加上盐防简单篡改。这个方案我在多个模拟项目里检验过,不依赖外部加密芯片,只用单片机自身Flash和RAM就能完成。
先定流程:上位机把待升级固件切块写入Flash,同时计算一个带盐摘要。盐值是设备固定的4字节ID,比如0xA5、0x5A、0x00、0x01。计算时把固件所有字节和盐值拼成数据流,取MD5。bootloader收完固件并写入Flash后,再从Flash里按相同顺序读出所有字节,加上盐值计算摘要,和固件头里存放的摘要比对,一致才跳转到新固件。
这里的关键是盐值怎么放。我一般把盐值放在固件文件的末尾,而不是头部。因为bootloader解析固件头时通常不认识自定义扩展字段,把盐值作为数据流的最后几个字节,两边规则都写成"先算固件全文,再算盐值",代码最简单。
/* bootloader内的固件完整性校验函数 */ uint8_t boot_check_firmware(void) { MD5_CTX ctx; uint8_t digest[16]; uint8_t salt[] = {0xA5, 0x5A, 0x00, 0x01}; uint32_t offset = APP_START_ADDR; uint32_t app_size = get_app_size_from_header(APP_START_ADDR); if (app_size == 0 || app_size > APP_MAX_SIZE) { return 0; } MD5_Init(&ctx); while (offset < APP_START_ADDR + app_size) { uint32_t chunk_len = (offset + 512 <= APP_START_ADDR + app_size) ? 512 : (APP_START_ADDR + app_size - offset); uint8_t buf[512]; read_flash(buf, offset, chunk_len); MD5_Update(&ctx, buf, chunk_len); offset += chunk_len; } MD5_Update(&ctx, salt, sizeof(salt)); MD5_Final(digest, &ctx); return compare_digest_with_header(digest, APP_START_ADDR); }代码里read_flash是从Flash读数据到RAM缓冲,注意512字节的局部数组在栈上,如果bootloader栈不够,把这个数组改成静态。compare_digest_with_header从固件头的固定偏移读出期望摘要,逐字节比较。盐值必须和上位机生成工具里保持一致,否则两边算出来的摘要永远对不上。
还需要注意一个细节:固件文件本身可以不包含盐值,盐值作为追加块参与摘要计算。这样应用固件不需要知道自己被加盐,bootloader和上位机各持一份相同常量即可。以后如果换盐,改上位机和bootloader各自的一份常量就行,不动应用结构。
最后提醒,验证时一定要真的在应用里改一个字节,确认bootloader能拒绝启动,而不是只比对上位机生成的摘要。我之前就是只测了数据包摘要,没测bootloader这段Flash读取,结果一个偏移写错,直到样机升级后无法启动才排查出来。固化这个习惯之后,固件升级校验才真正稳定。希望这篇带盐方案能帮你少走一步弯路。
本文还有配套的精品资源,点击获取