4. MD5 算法详解:从流程到代码实现
MD5 这个算法,做嵌入式开发的应该都不陌生。串口烧录时校验镜像完整性,十有八九用的就是它。但说实话,很多人只是调个库函数,真问起里面怎么算的,往往答不上来。
我个人觉得,搞固件的人还是得把 MD5 吃透。为什么?因为烧录环境千奇百怪,串口丢包、波特率不稳、Flash 写入异常……你光会调库,出了问题根本没法定位。我当年调试一个量产烧录工具,就是靠手算 MD5 中间值才揪出 DMA 搬运数据时的字节序问题。
好,咱们今天就把 MD5 的底裤扒干净。从填充分块,到主循环逻辑,再到手写代码,一步不落。
4.1 MD5 算法整体流程
MD5 的输入是任意长度的消息,输出是一个 128 位(16 字节)的摘要。整个流程可以概括为四步:
- 填充消息:让消息长度对 512 取模等于 448
- 追加长度:在填充后的消息末尾加上 64 位的原始消息长度
- 初始化缓冲区:设置四个 32 位寄存器 A、B、C、D
- 主循环处理:以 512 位(64 字节)为一块,循环压缩
说白了,就是把任意长的数据切成固定大小的块,每块都搅进四个寄存器里,最后寄存器的值就是摘要。
核心要点:MD5 是小端序算法。所有多字节数据在内存中都是低字节在前。这一点在嵌入式平台上尤其容易踩坑——ARM 默认小端,但有些 MIPS 或 PowerPC 是大端,移植时一定要做字节序转换。
4.2 填充与分块
填充这一步,很多人觉得就是补零,其实没那么简单。MD5 的填充规则很明确:
- 先在消息末尾补一个
0x80(二进制 10000000) - 然后补
0x00,直到长度对 512 取模等于 448 - 最后追加 64 位的原始消息长度(单位是位,不是字节)
为什么是 448?因为 448 + 64 = 512,刚好凑满一块。你想想看,如果原始消息长度正好是 448 位,那还得再补 512 位——补一个 0x80 再加 447 个 0x00,最后加 64 位长度,凑成两块。
我曾经踩过的坑:在 STM32 上实现 MD5 时,我直接用 sizeof 算消息长度,忘了单位是位不是字节。结果算出来的摘要跟 PC 端对不上,查了整整一下午。后来发现是长度左移 3 位的问题。记住:长度值 = 字节数 × 8。
分块就更直接了。填充后的消息长度一定是 512 位的整数倍。每 64 字节切一块,依次送入主循环。如果消息很大(比如几百 KB 的固件镜像),就一块一块地喂,每块处理完更新 A、B、C、D 的值。
4.3 初始化缓冲区
MD5 用四个 32 位寄存器,初始值是固定的:
| 寄存器 | 初始值(十六进制) | 说明 |
|---|---|---|
| A | 0x67452301 |
低 32 位 |
| B | 0xEFCDAB89 |
次低 32 位 |
| C | 0x98BADCFE |
次高 32 位 |
| D | 0x10325476 |
高 32 位 |
注意看这些值的字节序。比如 0x67452301,在内存里实际存的是 01 23 45 67。这就是小端序。我刚开始学 MD5 时,对着 RFC 文档看了半天,总觉得初始值写错了——其实没错,是字节序在捣鬼。
4.4 主循环逻辑
主循环是 MD5 的核心,也是最难啃的部分。每块 64 字节,要经过 64 轮运算。这 64 轮分成 4 组,每组 16 轮,每组的逻辑函数不同。
4.4.1 四个轮函数
| 轮次 | 函数名 | 表达式 | 说明 |
|---|---|---|---|
| 第 1 轮(0-15) | F | (B & C) | (~B & D) |
位选择:B 控制选 C 还是 D |
| 第 2 轮(16-31) | G | (B & D) | (C & ~D) |
位选择:D 控制选 B 还是 C |
| 第 3 轮(32-47) | H | B ^ C ^ D |
异或 |
| 第 4 轮(48-63) | I | C ^ (B | ~D) |
混合异或与或 |
每一轮做的事情其实就这几步:
- 取当前轮的轮函数值
F - 加上
A、消息子块M[k]、常数T[i] - 循环左移
s位 - 加上
B - 把
A、B、C、D循环右移一个位置
用公式表示就是:
a = b + ((a + F(b,c,d) + M[k] + T[i]) <<< s)
其中 <<< 表示循环左移。每轮结束后,四个寄存器做一次轮换:(a, b, c, d) → (d, a, b, c)。
我的个人习惯:调试 MD5 时,我会在每轮结束后打印 A、B、C、D 的值,跟 RFC 1321 附录里的测试向量逐轮比对。只要有一轮对不上,立马就能定位到问题。这比等整个摘要算完再对比高效得多。
4.4.2 常数表 T[i]
T[i] 是 64 个常数,由正弦函数生成:T[i] = floor(2^32 × |sin(i+1)|)。i 从 0 到 63。这些常数的作用是增加随机性,让输出对输入变化更敏感。
实际开发中没人现场算这些常数,都是直接查表。我一般把表放在 ROM 里,省 RAM。
4.4.3 消息子块索引
每轮用到的消息子块 M[k] 的索引 k 也不同:
- 第 1 轮:
k = i(顺序取) - 第 2 轮:
k = (5×i + 1) % 16 - 第 3 轮:
k = (3×i + 5) % 16 - 第 4 轮:
k = (7×i) % 16
这种乱序取法,是为了让每个消息字节都能充分影响到所有寄存器位。
4.5 代码实现
下面给一个完整的 MD5 实现。为了可读性,我尽量写得清晰,没有做极致优化。实际产品中可以用查表代替 switch-case 来提速。
#include <stdint.h>
#include <string.h>
// 循环左移宏
#define LEFT_ROTATE(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
// 常数表 T[64]
static const uint32_t T[64] = {
0xd76aa478, 0xe8c7b756, 0x242070db, 0xc1bdceee,
0xf57c0faf, 0x4787c62a, 0xa8304613, 0xfd469501,
0x698098d8, 0x8b44f7af, 0xffff5bb1, 0x895cd7be,
0x6b901122, 0xfd987193, 0xa679438e, 0x49b40821,
0xf61e2562, 0xc040b340, 0x265e5a51, 0xe9b6c7aa,
0xd62f105d, 0x02441453, 0xd8a1e681, 0xe7d3fbc8,
0x21e1cde6, 0xc33707d6, 0xf4d50d87, 0x455a14ed,
0xa9e3e905, 0xfcefa3f8, 0x676f02d9, 0x8d2a4c8a,
0xfffa3942, 0x8771f681, 0x6d9d6122, 0xfde5380c,
0xa4beea44, 0x4bdecfa9, 0xf6bb4b60, 0xbebfbc70,
0x289b7ec6, 0xeaa127fa, 0xd4ef3085, 0x04881d05,
0xd9d4d039, 0xe6db99e5, 0x1fa27cf8, 0xc4ac5665,
0xf4292244, 0x432aff97, 0xab9423a7, 0xfc93a039,
0x655b59c3, 0x8f0ccc92, 0xffeff47d, 0x85845dd1,
0x6fa87e4f, 0xfe2ce6e0, 0xa3014314, 0x4e0811a1,
0xf7537e82, 0xbd3af235, 0x2ad7d2bb, 0xeb86d391
};
// 每轮左移位数
static const uint32_t S[64] = {
7,12,17,22, 7,12,17,22, 7,12,17,22, 7,12,17,22,
5, 9,14,20, 5, 9,14,20, 5, 9,14,20, 5, 9,14,20,
4,11,16,23, 4,11,16,23, 4,11,16,23, 4,11,16,23,
6,10,15,21, 6,10,15,21, 6,10,15,21, 6,10,15,21
};
// 轮函数
#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)))
// MD5 上下文
typedef struct {
uint32_t state[4]; // A, B, C, D
uint64_t count; // 已处理的位数
uint8_t buffer[64]; // 当前块
} MD5_CTX;
// 初始化
void md5_init(MD5_CTX *ctx) {
ctx->state[0] = 0x67452301;
ctx->state[1] = 0xEFCDAB89;
ctx->state[2] = 0x98BADCFE;
ctx->state[3] = 0x10325476;
ctx->count = 0;
}
// 处理一个 64 字节块
static void md5_transform(MD5_CTX *ctx, const uint8_t block[64]) {
uint32_t a = ctx->state[0];
uint32_t b = ctx->state[1];
uint32_t c = ctx->state[2];
uint32_t d = ctx->state[3];
uint32_t M[16];
uint32_t i, k, f, g;
// 小端序读取 16 个 32 位字
for (i = 0; i < 16; i++) {
M[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);
}
// 64 轮主循环
for (i = 0; i < 64; i++) {
if (i < 16) {
f = F(b, c, d);
g = i;
} else if (i < 32) {
f = G(b, c, d);
g = (5 * i + 1) % 16;
} else if (i < 48) {
f = H(b, c, d);
g = (3 * i + 5) % 16;
} else {
f = I(b, c, d);
g = (7 * i) % 16;
}
uint32_t temp = d;
d = c;
c = b;
b = b + LEFT_ROTATE(a + f + M[g] + T[i], S[i]);
a = temp;
}
// 更新状态
ctx->state[0] += a;
ctx->state[1] += b;
ctx->state[2] += c;
ctx->state[3] += d;
}
// 更新(可多次调用,处理流式数据)
void md5_update(MD5_CTX *ctx, const uint8_t *data, size_t len) {
size_t index = (size_t)(ctx->count & 0x3F); // 当前块内偏移
ctx->count += (len << 3); // 更新位数
size_t part_len = 64 - index;
size_t i = 0;
if (len >= part_len) {
memcpy(ctx->buffer + index, data, part_len);
md5_transform(ctx, ctx->buffer);
for (i = part_len; i + 63 < len; i += 64) {
md5_transform(ctx, data + i);
}
index = 0;
}
memcpy(ctx->buffer + index, data + i, len - i);
}
// 收尾,输出摘要
void md5_final(MD5_CTX *ctx, uint8_t digest[16]) {
size_t index = (size_t)(ctx->count & 0x3F);
size_t pad_len = (index < 56) ? (56 - index) : (120 - index);
// 填充:0x80 + 0x00...
uint8_t padding[64];
padding[0] = 0x80;
memset(padding + 1, 0, pad_len - 1);
md5_update(ctx, padding, pad_len);
// 追加 64 位长度(小端序)
uint8_t len_buf[8];
uint64_t bits = ctx->count;
for (int i = 0; i < 8; i++) {
len_buf[i] = (uint8_t)(bits >> (i * 8));
}
md5_update(ctx, len_buf, 8);
// 输出摘要(小端序)
for (int i = 0; i < 4; i++) {
digest[i*4] = (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);
}
}
代码要点:
md5_update支持流式处理,可以分多次喂数据。这在串口烧录时特别有用——边收边算,不用等整个镜像收完。- 填充逻辑在
md5_final里完成。注意pad_len的计算:如果当前块剩余空间不够放 64 位长度,就补满当前块再加一个新块。 - 所有多字节数据都按小端序处理。如果你在 PowerPC 上跑这段代码,记得在读取
M[i]和输出digest时做字节序转换。
4.6 避坑指南
最后分享几个我实际项目中踩过的坑:
- 字节序问题:MD5 标准是小端序。如果你的平台是大端,必须在读取消息块和输出摘要时做转换。我见过有人直接在 state 初始化时把值写成大端序——那是错的,state 本身也要按小端序理解。
- 长度单位:追加的 64 位长度是位,不是字节。处理大文件时,长度可能超过 2^32 位,要用 64 位变量。
- 填充时机:一定要在所有消息数据都喂完之后再做填充。如果你在
md5_update里提前填充了,摘要就错了。 - 内存对齐:有些 MCU 对未对齐访问会触发异常。如果传入的数据指针不是 4 字节对齐的,建议先 memcpy 到对齐的缓冲区再处理。
调试小技巧:我习惯在 md5_transform 里加一个条件编译的调试打印,把每轮的 a、b、c、d 值输出到串口。然后用 RFC 1321 附录里的测试向量(比如空字符串的 MD5 是 d41d8cd98f00b204e9800998ecf8427e)逐轮比对。只要有一轮对不上,立马就能定位是轮函数写错了还是移位位数错了。