4. MD5 算法详解:从流程到代码实现

MD5 这个算法,做嵌入式开发的应该都不陌生。串口烧录时校验镜像完整性,十有八九用的就是它。但说实话,很多人只是调个库函数,真问起里面怎么算的,往往答不上来。

我个人觉得,搞固件的人还是得把 MD5 吃透。为什么?因为烧录环境千奇百怪,串口丢包、波特率不稳、Flash 写入异常……你光会调库,出了问题根本没法定位。我当年调试一个量产烧录工具,就是靠手算 MD5 中间值才揪出 DMA 搬运数据时的字节序问题。

好,咱们今天就把 MD5 的底裤扒干净。从填充分块,到主循环逻辑,再到手写代码,一步不落。

4.1 MD5 算法整体流程

MD5 的输入是任意长度的消息,输出是一个 128 位(16 字节)的摘要。整个流程可以概括为四步:

  1. 填充消息:让消息长度对 512 取模等于 448
  2. 追加长度:在填充后的消息末尾加上 64 位的原始消息长度
  3. 初始化缓冲区:设置四个 32 位寄存器 A、B、C、D
  4. 主循环处理:以 512 位(64 字节)为一块,循环压缩

说白了,就是把任意长的数据切成固定大小的块,每块都搅进四个寄存器里,最后寄存器的值就是摘要。

核心要点:MD5 是小端序算法。所有多字节数据在内存中都是低字节在前。这一点在嵌入式平台上尤其容易踩坑——ARM 默认小端,但有些 MIPS 或 PowerPC 是大端,移植时一定要做字节序转换。

MD5 算法整体流程 MD5 算法整体流程 原始消息(任意长度) 填充 + 追加 64 位长度 分块(每块 512 位) 初始化 A B C D(4×32 位) 主循环压缩(64 轮) 处理下一块 128 位摘要输出

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) 混合异或与或

每一轮做的事情其实就这几步:

  1. 取当前轮的轮函数值 F
  2. 加上 A、消息子块 M[k]、常数 T[i]
  3. 循环左移 s 位
  4. 加上 B
  5. 把 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)逐轮比对。只要有一轮对不上,立马就能定位是轮函数写错了还是移位位数错了。