从零手写SHA-256:纯C语言实现哈希算法与踩坑总结
2026/9/2 9:26:34 网站建设 项目流程

简介:SHA-256哈希算法的C语言实现源码,面向嵌入式开发者、密码学初学者以及需要在项目中集成数据校验、数字签名或区块链相关功能的程序员。该资源将完整算法封装在一个C源文件与一个头文件中,共2个文件,压缩包仅3KB,代码紧凑无额外依赖,可直接复制到Linux、Windows或嵌入式平台编译运行。实现由Brad Conte编写,经使用者实际验证可用,能正确处理任意长度输入并输出32字节摘要,适合作为独立校验工具或更大工程的安全模块。研读源码可掌握SHA-256的消息填充(含长度扩展)、压缩函数中的64轮迭代、初始哈希值、轮常量以及字节序转换等关键实现细节,对理解现代密码哈希原理和撰写安全代码很有帮助。目前已有2999人学习下载,是轻量级算法学习与工程落地的优质参考。 最近在做一个不依赖第三方库的文件完整性校验工具,需要在纯 C 语言环境里实现 sha256 哈希算法。搜了一圈网上的实现,不是套了一层 OpenSSL 封装,就是写得又长又绕,根本不适合拿来学习。所以我干脆翻出 FIPS 180-4 标准文档,从零手写了一个 sha256,顺便把整个算法的细节和踩坑过程完整过了一遍。

这篇文章就是这次实践的全记录。我会先从哈希算法到底解决什么问题讲起,再拆解 sha256 的原理和填充规则,然后给出一份可以直接编译运行的 C 语言实现,最后把我调试过程中遇到的那些坑和排查思路整理出来。适合已经掌握了 C 语言指针、数组、位运算基础,想深入理解哈希算法底层实现的人,也适合做课程设计或者嵌入式开发需要在无依赖环境下计算摘要的朋友。

1. 从需求到方案:为什么手写 SHA-256

1.1 哈希算法解决什么问题

哈希算法的核心功能,是把任意长度的二进制数据,压缩成固定长度的摘要。sha256 的输入可以是几个字节,也可以是几个 GB 的文件,输出永远是 32 字节、64 个十六进制字符。这个摘要有两个关键特性:第一是单向性,从摘要推导不出原始数据;第二是雪崩效应,原文哪怕只改一个比特,输出就会面目全非。

我打个比方:哈希算法就像给每份文件办一张“身份证号”,但这个身份证号有严格限制——任何人都能根据文件算出号码,却没法根据号码反推出文件内容,而且两份不同的文件几乎不可能拿到同一个号码。正因为这些特性,sha256 被广泛用在文件完整性校验、软件包签名、密码存储、区块链等场景里。

1.2 为什么是 SHA-256,而不是 MD5

早期很多系统用的是 MD5 和 SHA-1,但这两个算法都已经被学术界找到了碰撞攻击方法——也就是能构造出两个不同内容却拥有相同摘要的输入。对安全要求高的场景来说,这等于身份证系统出现了“重号”,是不可接受的。

SHA-2 系列就是为了解决这个问题设计的,sha256 是其中应用最广的一个变体。它输出 256 比特摘要,碰撞攻击的理论复杂度是 2^128 次运算,目前没有已知的可行攻击手段。虽然是 2001 年发布的老算法,但至今仍然是工业界的中坚力量,在 SSL/TLS 证书、Git 版本管理、区块链账本里都能看到它的身影。

1.3 手写 C 语言实现的价值与适用人群

有人会问:OpenSSL、libgcrypt 不都有现成实现吗,为什么还要自己写?对我来说有三个原因。

第一是环境约束。我在做的是嵌入式方向的小工具,目标平台裁剪得很厉害,不可能为了算一个摘要就把整个 OpenSSL 拉进来,手写一个单文件实现最省心。第二是学习价值。sha256 这个算法设计得非常精巧,里面用到了循环右移、异或、选择函数、多数函数、消息调度这些经典技巧,自己写一遍的理解深度是“调用一下 API”完全比不了的。第三是可裁剪性。标准库实现要兼容各种场景,代码往往写得通用而冗长,自己实现可以根据需求砍到只剩核心逻辑。

当然我也要提醒一句:如果是生产环境且环境允许,直接用经过安全审计的开源库更稳妥。手写密码学算法容易在侧信道、内存清零、依赖注入这些细节上出问题。但作为学习、课程设计、嵌入式裸机场景,自己实现的版本完全够用。

2. 算法原理:看懂这五步就成功了一半

2.1 五步总览

sha256 的整个计算流程可以拆成五步:消息填充、解析消息块、设置初始哈希值、压缩函数处理、输出摘要。前两步是预处理,中间是初始化,最后两步是核心计算。

打个比方,这就像做一道需要先处理食材的菜:填充和解析是把乱七八糟的原材料切成统一大小的块,初始化哈希值是准备好锅和油,压缩函数是不断翻炒让味道均匀,最后输出摘要就是出锅装盘。每个环节都有讲究,尤其是第一步填充,写错的人最多。

2.2 填充规则:最容易写错的细节

sha256 每次处理 64 字节(512 比特)的数据块。填充的目标,是让消息总长度变成 64 的倍数,同时还要在末尾留出 8 字节专门存放原始消息的比特长度。

填充规则有三步。第一步,在消息末尾追加一个二进制位 1,对应到字节就是 0x80。第二步,不断追加二进制位 0,直到消息长度模 64 等于 56。第三步,追加 8 字节的大端整数,数值是原始消息的比特长度。

注意,最后一步存的是“比特数”,不是字节数。比如消息 “abc” 长度是 3 字节,也就是 24 比特,最后 8 字节存的就是整数 24。还有一个容易忽略的坑:填充是强制性的,哪怕原始消息长度已经是 64 的倍数,也要额外补一个 64 字节的块,不能跳过。原因很简单——最后 8 字节必须放长度信息,如果消息正好对齐,不补块就没地方放了。

2.3 六个逻辑函数与两类右移

sha256 的压缩循环里用到了六个逻辑函数,它们全部基于 32 位无符号整数的位运算。

Ch(e, f, g) = (e AND f) XOR ((NOT e) AND g),这个函数像个“二选一开关”:当 e 的某一位是 1 时,输出 f 的对应位;当 e 是 0 时,输出 g 的对应位。

Maj(a, b, c) = (a AND b) XOR (a AND c) XOR (b AND c),这是“少数服从多数”:a、b、c 三个位里至少两个是 1,结果就是 1。

另外还有两组函数分别用于压缩循环和消息调度。压缩循环里用的是大写的 Σ0 和 Σ1,由循环右移和异或组合而成;消息调度里用的是小写的 σ0 和 σ1,由循环右移加普通右移再异或。这里必须强调两种右移的区别:循环右移是把右边溢出的位补到左边,类似转圈;逻辑右移则是高位补 0。C 语言里对无符号整数做右移是逻辑右移,这个特性后面实现时会用到。

2.4 64 轮压缩:消息调度与寄存器轮转

预处理完成后,每个 64 字节块要经过 64 轮压缩计算。计算过程中有 8 个 32 位工作变量,记作 a、b、c、d、e、f、g、h,初始值来自上一轮的状态,第一轮来自初始哈希值 H0-H7。

每一轮的计算分两步。第一步,把本轮的常量 K[t] 和消息调度值 W[t] 注入计算: T1 = h + Σ1(e) + Ch(e, f, g) + K[t] + W[t] T2 = Σ0(a) + Maj(a, b, c)

第二步,把 8 个变量像流水线一样整体右移一位:h 变成 g,g 变成 f,f 变成 e,e 变成 d + T1,d 变成 c,c 变成 b,b 变成 a,a 变成 T1 + T2。

这里 W[t] 是消息调度值。前 16 个 W 直接来自当前 64 字节块的 16 个 32 位字,后 48 个 W 由前面已有的 W 通过 σ0、σ1 和加法推导出来。这样每一轮的计算都依赖更多历史信息,消息的任何微小变化都会迅速扩散到所有寄存器,最终产生雪崩效应。K[t] 是 64 个固定常量,它们来自前 64 个质数的立方根小数部分,起到打乱规律的作用。

3. 工程落地:手把手写一个可运行的 SHA-256

3.1 数据结构与常量表

实现 sha256 之前,先定几个关键的数据结构。所有中间计算都用 32 位无符号整数,C 语言里最稳妥的类型是 uint32_t,定义在 stdint.h 头文件里。不要用 unsigned int,虽然大多数平台上它是 32 位,但 C 标准只保证它至少有 16 位,跨平台移植时容易出问题。

常量表有两张。第一张是 64 个 K 常量,直接写死成 const 数组,放在只读数据段。第二张是 8 个初始哈希值 H0-H7,它们来自前 8 个质数 2、3、5、7、11、13、17、19 的平方根小数部分的前 32 位。这些常量不需要你自己算,标准文档里有现成的,直接用就行。

3.2 函数拆分与接口设计

我把实现拆成了两层。底层是 sha256_transform 函数,只处理一个 64 字节块,负责消息调度、64 轮压缩和状态更新。上层是 sha256 函数,负责消息填充、分块调用 transform、最后输出摘要。

这种拆分方式的好处是逻辑清晰、容易测试。先保证 transform 函数正确,再验证上层填充逻辑,出问题的时候可以快速定位。如果你的场景需要处理大文件,可以把上层函数进一步改造成流式接口,分成 update 和 final 两个阶段,这个我在后面第 6 节再展开。

3.3 核心代码实现

下面是我整理的一份可编译运行的完整实现,去掉多余的注释,保留关键说明:

#include <stdio.h> #include <string.h> #include <stdint.h> #define ROTR(x, n) (((x) >> (n)) | ((x) << (32 - (n)))) static const uint32_t K[64] = { 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2 }; static uint32_t H[8] = { 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19 }; static void sha256_transform(uint32_t state[8], const uint8_t block[64]) { uint32_t w[64]; uint32_t a, b, c, d, e, f, g, h; uint32_t t1, t2; int i; for (i = 0; i < 16; i++) { w[i] = ((uint32_t)block[i * 4] << 24) | ((uint32_t)block[i * 4 + 1] << 16) | ((uint32_t)block[i * 4 + 2] << 8) | ((uint32_t)block[i * 4 + 3]); } for (i = 16; i < 64; i++) { uint32_t s0 = ROTR(w[i - 15], 7) ^ ROTR(w[i - 15], 18) ^ (w[i - 15] >> 3); uint32_t s1 = ROTR(w[i - 2], 17) ^ ROTR(w[i - 2], 19) ^ (w[i - 2] >> 10); w[i] = w[i - 16] + s0 + w[i - 7] + s1; } a = state[0]; b = state[1]; c = state[2]; d = state[3]; e = state[4]; f = state[5]; g = state[6]; h = state[7]; for (i = 0; i < 64; i++) { uint32_t S1 = ROTR(e, 6) ^ ROTR(e, 11) ^ ROTR(e, 25); uint32_t ch = (e & f) ^ (~e & g); uint32_t temp1 = h + S1 + ch + K[i] + w[i]; uint32_t S0 = ROTR(a, 2) ^ ROTR(a, 13) ^ ROTR(a, 22); uint32_t maj = (a & b) ^ (a & c) ^ (b & c); uint32_t temp2 = S0 + maj; h = g; g = f; f = e; e = d + temp1; d = c; c = b; b = a; a = temp1 + temp2; } state[0] += a; state[1] += b; state[2] += c; state[3] += d; state[4] += e; state[5] += f; state[6] += g; state[7] += h; } void sha256(const uint8_t *data, size_t len, uint8_t out[32]) { uint32_t state[8]; uint8_t block[64]; size_t i; uint64_t bitlen = (uint64_t)len * 8; memcpy(state, H, sizeof(H)); while (len >= 64) { sha256_transform(state, data); data += 64; len -= 64; } memset(block, 0, 64); memcpy(block, data, len); block[len] = 0x80; if (len >= 56) { sha256_transform(state, block); memset(block, 0, 64); } for (i = 0; i < 8; i++) { block[56 + i] = (uint8_t)(bitlen >> (56 - i * 8)); } sha256_transform(state, block); for (i = 0; i < 8; i++) { out[i * 4] = (uint8_t)(state[i] >> 24); out[i * 4 + 1] = (uint8_t)(state[i] >> 16); out[i * 4 + 2] = (uint8_t)(state[i] >> 8); out[i * 4 + 3] = (uint8_t)state[i]; } } int main(void) { const char *msg = "abc"; uint8_t digest[32]; int i; sha256((const uint8_t *)msg, strlen(msg), digest); for (i = 0; i < 32; i++) { printf("%02x", digest[i]); } printf("\n"); return 0; }

代码里的 ROTR 宏要注意,x 和 n 都要加括号,否则传入复杂表达式时可能因为运算符优先级产生隐蔽错误。sha256_transform 函数里从 block 组装 w[0] 到 w[15] 时,用的是手动字节拼接,这是故意为之——SHA-256 规定多字节数据按大端解释,x86 这类小端机器上不能直接 memcpy 到 uint32_t,必须逐字节移位拼接。

3.4 编译运行与结果验证

把代码保存为 sha256.c,在 Linux 终端里执行:

gcc -O2 -Wall sha256.c -o sha256 ./sha256

程序输出字符串 “abc” 的 sha256 摘要,期望值是 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad。

如果你用 Windows 且还没装编译器,建议先装 MinGW-w64 或者直接用 WSL。在 VSCode 里配置 C 语言环境的话,安装 C/C++ 扩展后配置好 gcc 路径就行,终端里跑上面的编译命令效果一样。至于 IDE,初学者最容易踩的坑是编译器路径没配好,导致“无法打开源文件”之类的报错,这种时候先确认 gcc -v 能正常输出版本信息,再回过来折腾编辑器配置。

4. 测试验证:别急着说“写完了”

4.1 标准测试向量

手写密码学算法,最忌讳的是“跑出结果就觉得对了”。必须用标准测试向量验证,也就是用一组已知输入和对应的权威摘要来检验实现。

我建议至少跑下面这几组:

输入期望的 sha256 摘要
空字符串 ""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
"abc"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
"hello world"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9
56字节串 "abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1

验证方法很简单,Linux 下用系统自带的 sha256sum 命令交叉对比:

echo -n "abc" | sha256sum

注意 echo 必须加 -n,否则会把换行符也算进去,结果完全不一样。这也是新手最容易犯的错误之一。

4.2 边界长度测试

除了标准测试向量,还要专门测边界长度。因为填充逻辑分支多,最容易在边界处出错。重点关注这几个长度:55 字节、56 字节、64 字节、65 字节。

为什么是这些数字?因为一个 64 字节块里,数据部分最多占 55 字节(64 减 1 字节 0x80 减 8 字节长度)。55 字节正好填满一个块,不需要额外块;56 字节时 0x80 已经放到第 56 字节,长度字段放不下了,必须用第二个块;64 字节时整个块都是数据,填充从下一个块开始。把这些边界都跑一遍,填充代码才算真的可靠。

4.3 大文件与流式处理

如果你要算大文件的哈希,一次性把整个文件读进内存再调用 sha256 函数显然不现实。我的做法是分块读取,每读 64 字节就调用一次 sha256_transform,最后再处理末尾的填充。这就是把一次性接口改造成流式接口的思路。

不过要注意,流式处理时必须在内部维护“已经处理的字节数”和“缓冲区里剩下的字节数”,最后填充时才能正确写入原始的比特长度。这个改造我在第 6 节详细说。上面这个一次性版本,处理几 KB 到几 MB 的数据也没问题,只是内存占用会随输入线性增长,不适合大文件。

5. 踩坑实录与调试定位技巧

5.1 字节序:小端机器上的大端算法

我调试时遇到的头号问题就是字节序。SHA-256 算法里的所有多字节整数,包括消息的每个 32 位字、填充的长度字段、最终输出的摘要,全部按大端字节序解释。而 x86 和绝大多数 ARM 处理器都是小端,也就是内存里低地址存低位字节。

最容易踩坑的是消息调度的第一步:从 block 里取 W[0] 到 W[15] 时,不能直接((uint32_t*)block)[0]这样强转,在小端机器上读出来的值完全反了。必须手动移位拼接,就像我代码里写的那样。输出摘要时也一样,要把 state[i] 按大端拆成 4 个字节。

调试方法很简单:算 “abc” 的时候,打印 w[0] 看看是不是 0x61626380。字符串 “abc” 的 ASCII 码是 0x61、0x62、0x63,填充后第一个块的前 4 字节应该是 61 62 63 80,按大端组成 0x61626380。如果打印出来是 0x80636261 之类的反序,基本可以断定字节序处理错了。

5.2 有符号右移的隐藏陷阱

第二个坑是符号类型右移。C 标准规定,对有符号负数的右移是实现定义行为,大多数编译器会做算术右移——高位用符号位填充。在 sha256 里,很多中间值的高位恰好是 1,如果误用了 int 类型,右移后高位补 1,异或结果立刻出错,而且错得毫无规律,非常难排查。

解决方法是全程使用 uint32_t,保证右移一定是逻辑右移。我在代码里定义的 ROTR 宏也依赖这一点,如果 x 是有符号类型,循环右移的结果同样会出错。这一点对嵌入式开发的读者尤其重要,因为交叉编译器的行为可能和本机编译器不完全一致。

5.3 填充长度算错:字节和比特分不清

第三个高频错误是把长度字段的数值算错。填充规则最后 8 字节存的是原始消息的比特长度,不是字节长度。比如 3 字节的消息,长度字段应该是 24,也就是 0x0000000000000018。

另一个相关错误是长度字段的字节序。我之前一开始写成了小端序,结果空字符串和短消息的摘要全不对,最后逐字节对比填充结果才发现问题。建议在调试时写一个小函数,把填充后的完整块打印出来,对照标准文档手动比对一遍,很快就能定位。

5.4 调试 SHA-256 的实用思路

我的调试顺序一般是这样:先跑空字符串,再跑 “abc”。如果空字符串结果不对,说明填充或初始状态有问题;如果空字符串对了但 “abc” 不对,说明对非空消息的块处理有问题。每改一次代码,就用测试向量回归一遍,而不是等到最后才整体验证。

另一个技巧是用 hashlib 交叉验证中间状态。Python

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询