STM32F103上MD5算法C语言实现详解:从填充到测试向量

发布时间:2026/9/15 11:50:17
STM32F103上MD5算法C语言实现详解:从填充到测试向量 简介一套基于STM32F103环境编译验证通过的MD5加密算法C语言完整工程面向嵌入式开发者及需要快速集成哈希校验功能的工程师。代码按CORE、User、Project分层组织将MD5核心逻辑与硬件启动、用户应用分离具备较高可移植性移植到其他C语言平台时只需调整少量硬件接口。压缩包共60个文件以头文件与C源文件为主辅以启动文件及工程配置整体约269KB结构清晰便于阅读。已有309人学习下载适合对照标准流程学习MD5的初始化、消息填充、四轮变换与摘要输出并可直接在STM32上进行实测验证。需留意MD5在高安全场景下已不建议单独使用可结合业务升级为SHA-256等更安全算法。1. 为什么在 STM32F103 上用 C 语言实现 MD5 仍然值得看MD5 已经被证明不是密码学意义上的安全哈希但在固件升级校验、配置文件一致性比对、小型嵌入式设备的协议消息摘要这些场景它依然是体积小、实现成本最低的选择。很多刚接触 STM32 的开发者拿到一份带完整 C 语言的 MD5 工程往往只关注“能不能跑”却忽略了更关键的问题这段代码移植到别的 C 平台为什么容易、在 Cortex-M3 上内存开销到底多大、测试向量怎么算才是对的。这份基于 STM32F103 的 MD5 示例工程并不复杂核心代码只有 MD5.c 和 MD5.h但把消息填充、四轮压缩函数、缓冲区初始化这些细节放在一个真实 MCU 工程里演示比单纯看算法伪代码直观得多。适合两类人一是要做嵌入式协议栈、需要快速集成哈希校验的工程师二是想搞懂 MD5 底层拆解并准备移植到其它 MCU 的学习者。2. MD5 算法核心与 C 语言数据表示2.1 128 位哈希、4 轮压缩和状态缓冲区的关系MD5 的输出是 128 位也就是 16 个字节。这 128 位并不来自消息本身而是算法维护了 4 个 32 位无符号整数A、B、C、D作为状态消息经过填充后按 512 位分组每一组通过 4 轮共 64 步运算去更新这个状态。用 C 语言实现时这 4 个整数通常定义为uint32_t。由于 STM32F103 的 Cortex-M3 是 32 位内核对uint32_t的读写和移位操作是一条指令就能完成的事所以标准库的stdint.h里定义的uint32_t在这个平台上非常自然。移植到 8 位单片机上时uint32_t会退化成多字节操作性能明显下降但算法逻辑不受影响这正是“代码移植性高”的来源。常见的错误是把 MD5 的 128 位输出直接理解成字符串。实际上内存里就是 4 个 32 位整数按小端字节序排出 16 字节。像abc这样的输入正确结果是900150983cd24fb0d6963f7d28e17f72在 STM32 上 dump 内存时0x90015098 会按小端存成98 50 01 90。这一点在做串口打印校验值和用上位机工具比对时最容易出问题。typedef struct { uint32_t state[4]; /* A, B, C, D */ uint32_t count[2]; /* 消息总长度的 bit 数用 64 位保存 */ uint8_t buffer[64]; /* 512 位分组缓冲区 */ } MD5_CTX;这个结构体定义了 MD5 的核心上下文。count[2]是一个 64 位计数器的两个 32 位部分因为 MD5 要对输入消息总长度以 64 位长度编码写入填充尾。STM32F103 的 RAM 一般只有 20KB 到 64KB这个结构体只占约 84 字节放在任意工程里都不会有压力。2.2 消息填充必须自己实现别依赖编译器MD5 对所有输入消息都要先做填充先在消息后面补一个0x80然后补若干个0x00直到消息长度模 512 等于 448。最后再追加 8 字节的原始消息长度以 bit 为单位小端序。这意味着就算输入是空字符串也要填充出 64 字节的完整分组。在 STM32 上写 MD5 时最容易忽略的是长度溢出的问题。假设用串口或 SD 卡读文件做校验消息长度可能超过 2^32 bit需要把累计长度同时更新到count[1]和count[0]。很多简化版的 C 代码只用单个uint32_t记录字节数在 MCU 里虽然很难跑到 512MB 以上但做协议解析时如果直接传长度不确定的缓冲区累计溢出会导致校验值错误。void MD5_Update(MD5_CTX *ctx, const uint8_t *input, uint32_t len) { uint32_t index (ctx-count[0] 3) 0x3F; if ((ctx-count[0] len 3) (len 3)) { ctx-count[1]; } ctx-count[1] len 29; uint32_t part_len 64 - index; if (len part_len) { memcpy(ctx-buffer[index], input, part_len); MD5_Transform(ctx-state, ctx-buffer); for (uint32_t i part_len; i 63 len; i 64) { MD5_Transform(ctx-state, input[i]); } index 0; } else { part_len len; } memcpy(ctx-buffer[index], input[len - part_len], part_len); }这里的核心逻辑是先把上一次未处理完的字节补满一个 64 字节分组然后再循环处理完整分组。count[0]按位累加时检查回绕回绕则count[1]加一。len 29是因为len是字节数左移 3 位后超出 32 位的部分由高位移出。注意最后一行memcpy处理的是剩余不足一组的数据不是错误。index必须取自count[0]的低 6 位这正好对应缓冲区里已有的字节数。2.3 辅助函数 F、G、H、I 的最佳化写法MD5 四轮分别使用不同的非线性函数对应的 C 宏通常写成#define F(x, y, z) (((x) (y)) | (~(x) (z))) #define G(x, y, z) (((x) (z)) | ((y) ~(z))) #define H(x, y, z) ((x) ^ (y) ^ (z)) #define I(x, y, z) ((y) ^ ((x) | ~(z)))这四个函数都是逐位逻辑运算Cortex-M3 一条AND、ORR、EOR、MVN指令就能完成所以没必要用查表法。在优化等级开到-O2时编译器会把相邻的多次移位和加减法合并这比手工展开更可靠。每轮 16 步运算中还依赖 64 个常量K[i]它们来自abs(sin(i))的整数部分。这些常量可以直接用一个static const uint32_t K[64]数组存放避免运行时计算。在 STM32F103 上建议把这张表放到 Flash 里默认的const变量在 MDK-ARM 中会被分配到只读数据段不会占用 RAM。如果你的工程配置了将常量拷贝到 RAM需要检查链接脚本否则 256 字节的常量表会白占内存。3. 工程结构从 STM32F103 标准库到可移植的 MD5 模块3.1 示例工程的目录划分与启动文件这个工程使用的是 STM32F10x 标准外设库目录结构里值得关注的是CORE、User和Project三部分。CORE下的startup_stm32f10x_hd.s是大容量芯片启动文件startup_stm32f10x_md.s是中容量芯片启动文件。如果你的 F103 是 64KB Flash就选md.s256KB 以上 Flash 选hd.s。core_cm3.c和core_cm3.h是 Cortex-M3 内核抽象层里面定义了 MDK、IAR、GCC 下都能用的寄存器操作接口。User目录里的bsp通常是板级支持包示例工程中包含了uart驱动作用是把 MD5 计算结果显示到串口。MD5目录则是核心算法模块只有MD5.c和MD5.h不依赖 MCU 的外设寄存器。这一点是移植性的关键MD5 模块内部只调用了memcpy、memset和对uint32_t的位运算没有包含任何stm32f10x.h头文件。这意味着你可以直接把这个目录复制到某个 PC 端 C 工程里靠标准 C 库编译运行用于生成测试向量。Project/MD5.uvprojx - MDK-ARM 工程文件 CORE/startup_stm32f10x_md.s - 中等容量 F103 启动代码 CORE/core_cm3.c/.h - Cortex-M3 内核抽象层 User/main.c - 入口调用 MD5 并输出结果 User/bsp/uart - 串口初始化和打印函数 MD5/MD5.c/.h - 不依赖硬件的算法实现 STM32F10x_FWLib - 标准外设库这种分层的意义在于MD5模块被设计成纯计算单元main.c只负责构造输入数据、调用MD5_Init/MD5_Update/MD5_Final然后通过串口打印结果。替换成你自研的printf重定向或者改用 OLED 显示都不需要改动算法模块。3.2 数据类型与字节序移植注意事项移植到非 32 位平台时第一件事是确认uint32_t是否真的存在且位宽正确。C89 编译器没有stdint.h很多老式嵌入式编译器需要手动定义#if defined(__ICCARM__) || defined(__CC_ARM) || defined(__GNUC__) #include stdint.h #else typedef unsigned int uint32_t; typedef unsigned char uint8_t; #endif字节序方面MD5 算法规定所有多字节数值都以小端格式写入填充和输出。在 STM32F103 上uint32_t本身就是小端所以直接把 state 数组的内存地址输出即可。但如果把这份代码移植到 AVR 或一些网络处理器上输出前需要逐字节重组。一个通用做法是用下面的宏来保证跨平台正确输出#define MD5_PUT_LE32(buf, val) do { \ (buf)[0] (uint8_t)((val) 0xFF); \ (buf)[1] (uint8_t)(((val) 8) 0xFF); \ (buf)[2] (uint8_t)(((val) 16) 0xFF); \ (buf)[3] (uint8_t)(((val) 24) 0xFF); \ } while (0)在 STM32 上这样做会多几条字节操作指令但好处是同一份代码可以直接在 x86 和 ARM 上得到相同输出。测试工程若只在 F103 上跑直接用memcpy输出也没有问题。3.3 串口打印 MD5 值的初始化流程示例工程中main.c通常会先初始化 UART 再执行 MD5 计算。串口驱动不用做得很复杂能输出字符串即可。需要注意波特率的选择F103 的USART时钟来自APB2系统时钟 72MHz 时USART_BRR的设置要使用RCC_APB2PeriphClockCmd使能时钟。很多人在移植时把bsp_uart_init()放在 MD5 计算之后才调用导致打印不出来。正确做法是先初始化调试串口再调用算法函数。int main(void) { UART_Init(115200); unsigned char test[] abc; MD5_CTX ctx; unsigned char digest[16]; MD5_Init(ctx); MD5_Update(ctx, test, 3); MD5_Final(digest, ctx); printf(MD5(\abc\) ); for (int i 0; i 16; i) { printf(%02x, digest[i]); } printf(\r\n); while (1); }这里计算的是abc的 MD5输出应为900150983cd24fb0d6963f7d28e17f72。MD5_Final内部会处理最后的分组填充把ctx-state[0]到ctx-state[3]按小端复制到digest中。如果你把digest直接按uint32_t数组打印会看到0x90015098这样的值那正是小端序的体现。4. 基于 STM32F103 的 MD5 运算流程与性能取舍4.1 初始化、Update 与 Final 的完整接口设计一个规范的 MD5 接口不应该要求使用者一次性给出全部数据因为嵌入式场景里串口收到的可能是分片数据、文件系统读出的可能是多段缓冲。因此把 API 分成三个函数是最合理的MD5_Init负责重置状态为算法标准值MD5_Update处理任意长度的输入块MD5_Final收尾并输出摘要。void MD5_Init(MD5_CTX *ctx) { ctx-count[0] 0; ctx-count[1] 0; ctx-state[0] 0x67452301; ctx-state[1] 0xefcdab89; ctx-state[2] 0x98badcfe; ctx-state[3] 0x10325476; }四个初始值来自 RFC 1321顺序不能颠倒。有些学习者为了记忆把0x67452301写成0x01234567这是错误。MD5 的初始状态不是按内存字节序排列的数值而是算法明确定义的常数。在MD5_Init之后如果立刻调用MD5_Final空字符串的摘要结果是d41d8cd98f00b204e9800998ecf8427e这个值常用来验证填充逻辑是否正确。4.2 MD5_Transform 的循环展开与 Flash 占用MD5 的压缩函数是算法核心每 64 字节调用一次。标准写法是四轮循环每轮 16 步。在 STM32F103 上如果开启编译器优化循环展开可以由编译器自动完成。但手动写全 64 步会让代码量急剧膨胀不是必须。这里给出第一轮前四步的示意实现展示参数如何参与运算static void MD5_Transform(uint32_t state[4], const uint8_t block[64]) { uint32_t a state[0], b state[1], c state[2], d state[3]; uint32_t x[16]; for (int i 0; i 16; i) { x[i] (uint32_t)block[i * 4] | ((uint32_t)block[i * 4 1] 8) | ((uint32_t)block[i * 4 2] 16) | ((uint32_t)block[i * 4 3] 24); } /* 第 1 轮 */ FF(a, b, c, d, x[0], 7, 0xd76aa478); FF(d, a, b, c, x[1], 12, 0xe8c7b756); FF(c, d, a, b, x[2], 17, 0x242070db); FF(b, c, d, a, x[3], 22, 0xc1bdceee); /* ... 后续 60 步 ... */ state[0] a; state[1] b; state[2] c; state[3] d; }其中FF(a,b,c,d,x,s,ac)展开为a b ((a F(b,c,d) x ac) s)。表示循环左移。C 语言里(value s) | (value (32 - s))即可实现。需要特别注意的是在循环左移之前a F(...) x ac可能溢出 32 位这是正常现象C 标准对无符号整数溢出定义为取模正好符合 MD5 要求。因此整个算法中所有加法都使用uint32_t千万不要改成int32_t有符号溢出的行为在 C 标准里是未定义的。性能方面F103 主频 72MHzFlash 为 0 等待时处理 1KB 数据大概耗时在几百微秒级别。如果从 SPI Flash 读取固件做校验瓶颈通常在读取接口而不是 MD5 运算。因此没必要为 MD5 单独开启 FPU 或 DMA算法里全是整数运算DMA 也无法加速逻辑计算。4.3 分块读取和内存不足的处理策略对于大文件校验STM32F103 的 RAM 不足以一次装入整个文件。常见做法是每次从文件系统读取 512 字节或 1024 字节循环调用MD5_Update最后再MD5_Final。这种方式与一次性全部读入内存的结果完全一致因为 MD5 本身就是流式计算的。uint8_t buf[512]; uint32_t bytes_read; MD5_CTX ctx; MD5_Init(ctx); while ((bytes_read read_file(buf, sizeof(buf))) 0) { MD5_Update(ctx, buf, bytes_read); } MD5_Final(digest, ctx);这里read_file是抽象的文件读取函数实际可能是f_read或SD_ReadDisk。关键在于每次read_file返回的长度不一定等于 512必须以实际读到的字节数传入MD5_Update否则会把缓冲区中未初始化的数据也算进去。我在调试时遇到过这样的问题读取最后一块时只有 100 字节却仍然传入 512导致校验值和 PC 端工具不一致。后来打印每次读取长度才定位到问题。如果内存实在紧张可以把buf缩小到 64 字节正好一个分组这是 MD5 模块的最小吞吐单位。但过小的缓冲区会增加循环调用次数对性能有轻微影响。在通信协议中常见的做法是把串口收到的每一个协议帧都直接喂给MD5_Update最后计算整包校验值这样不需要额外维护完整报文符合嵌入式协议栈的内存使用习惯。5. 移植性检查、测试向量与安全边界5.1 用 RFC 1321 测试集验证实现是否正确拿到一个 MD5 工程不要直接集成到业务代码里先跑标准测试向量。RFC 1321 中给出的三个经典用例是空字符串、a、abc。这三个用例能覆盖空消息、单字节消息、短消息三种填充路径。我在验证一份移植代码时通常还会加入 56 字节和 64 字节的边界用例因为 56 字节填充后正好差 8 字节写长度64 字节则触发第二个分组的处理。输入正确摘要空字符串d41d8cd98f00b204e9800998ecf8427eabc900150983cd24fb0d6963f7d28e17f721000 个ac3fcd3d76192e4007dfb496cca67e13b1000 个a这个用例把Update调用分成多次执行会更有价值。例如先送 1 字节再送 999 字节结果必须与一次性送 1000 字节相同。如果不同说明数据缓冲的边界处理有 bug。常见问题集中在MD5_Update里index len超过 64 时的memcpy溢出以及两次Update之间长度累计错误。5.2 移植到非 STM32 平台时的检查清单把这份代码从 STM32F103 移到其它平台需要依次检查几个二进制兼容点。首先确认编译器对uint32_t的定义ARMCC 和 GCC 都没问题但某些面向 8 位 MCU 的编译器可能没有stdint.h需要手动补充。其次查看MD5_CTX结构体的count[2]累计逻辑如果有代码使用uint32_t做高位必须保证该类型在目标平台确实是 32 位否则长度信息会截断。第三是字节序输出摘要前需要按小端组装如果目标平台是大端不能直接使用memcpy(digest, ctx-state, 16)而要逐个字节重排。#if __BYTE_ORDER__ __ORDER_BIG_ENDIAN__ for (int i 0; i 4; i) { digest[i * 4 0] (uint8_t)(ctx-state[i] 0xFF); digest[i * 4 1] (uint8_t)((ctx-state[i] 8) 0xFF); digest[i * 4 2] (uint8_t)((ctx-state[i] 16) 0xFF); digest[i * 4 3] (uint8_t)((ctx-state[i] 24) 0xFF); } #else memcpy(digest, ctx-state, 16); #endif另外不要在中断服务函数里直接调用MD5_Update除非你非常确定它能在中断周期内完成。MD5_Transform里的循环没有临界区保护如果与主循环并发调用会对同一份MD5_CTX造成竞态。协议栈的做法是先把收到的数据放进 FIFO由主循环统一取出来喂给 MD5这样既安全又不会破坏流式计算的顺序。5.3 MD5 的不安全场景与仍适用的场合网上常有人问“md5 解密”“md5 强比较绕过”这里的误区需要说清楚。MD5 不是加密算法它是哈希函数不存在可逆的“解密”。所谓“解密”通常是在彩虹表或字典里反查比如对弱密码123456任何在线查询站点都能直接给出e10adc3949ba59abbe56e057f20f883e。更严重的是MD5 存在碰撞攻击攻击者可以构造两个内容不同但摘要相同的文件。因此在需要抵抗恶意篡改的场景比如固件签名、证书指纹、登录密码存储都不建议继续使用 MD5。但它在以下场合仍然可以使用非对抗环境下的文件完整性校验、串口通信的偶发误码检测、内部工具生成短哈希。CSV 文件或批量导出文件的校验值核对就属于这一类。很多资料下载站把 MD5 校验值发出来只是为了让用户确认下载过程没有丢包或磁盘坏道而不是防御攻击者替换文件。5.4 最后的实用技巧在 PC 端用 C 语言交叉验证 STM32 结果由于 MD5 模块本身不依赖 STM32 外设可以把它单独抽出来在 PC 上编译运行用于对比 CSV 文件里记录的校验值。我通常会在主机端写一个 30 行的命令行工具读取文件分块计算摘要与嵌入式设备输出的摘要做字符串比对。这样在调试串口打印时不需要每次都用上位机软件手动计算 MD5。做法是把MD5.c和MD5.h与下面的main.c放在同一目录用gcc -O2 main.c MD5.c -o md5tool编译#include stdio.h #include string.h #include MD5.h int main(int argc, char *argv[]) { if (argc ! 2) { printf(usage: md5tool file\n); return 1; } FILE *fp fopen(argv[1], rb); if (fp NULL) return 1; MD5_CTX ctx; unsigned char buf[1024], digest[16]; MD5_Init(ctx); size_t n; while ((n fread(buf, 1, sizeof(buf), fp)) 0) { MD5_Update(ctx, buf, (uint32_t)n); } MD5_Final(digest, ctx); for (int i 0; i 16; i) printf(%02x, digest[i]); printf( %s\n, argv[1]); fclose(fp); return 0; }这个工具的思路与 STM32 上的文件分块校验一致。区别只有fread替换成了 F103 工程里的read_file。当你把这段代码放进工程时会发现 MD5 模块和平台相关的唯一关联就是内存拷贝和无符号整数运算这也就解释了为什么示例工程自称“代码移植性很高”。在调试 STM32 串口输出时把abc送入设备后看到900150983cd24fb0d6963f7d28e17f72再用 PC 端工具对同一个输入计算一次两边相同即可确认移植成功。本文还有配套的精品资源点击获取