简介:这是一套SHA256哈希算法的C语言实现源码,适合需要理解或使用SHA-2算法的开发者,常见于数字签名、SSL/TLS协议、文件校验等场景。代码完整实现了SHA256核心流程:从宏定义和常量声明开始,对任意长度输入进行填充(补1位、补0位并附加64位长度字段),将其分割为64字节数据块,借助循环右移、位运算等逻辑函数迭代更新四个32位中间变量,最终按大端序输出32字节摘要。压缩包内共4个文件,以C源文件为主,另附sha256说明文件以及.gitignore、.inscode工程配置;包体仅9KB,结构紧凑。目前已有136人学习浏览。源码注释详细、模块划分清楚,既可直接集成到项目中,也适合作为学习SHA256原理与C语言实现的参考。无论初学者还是需要快速落地的工程师,都能从中获得清晰的实现思路。 前段时间在做一个STM32上的OTA升级功能,需要在校验固件包时计算SHA256摘要。项目体积卡得紧,我不能把OpenSSL整个移植进来;网上找的几份C语言实现,要么依赖一堆平台库,要么风格混乱不敢直接用。那段时间正好任务不紧,我干脆静下心,自己从零写了一份SHA256的C代码。做完之后才发现,这个算法远没有想象中那么神秘,但里面真正的坑也不少。这篇文章就把我实现过程中的核心思路、代码片段和踩过的坑完整整理出来,希望能给你省点时间。
1. 项目背景:嵌入式固件校验的重担为什么落在SHA256上
1.1 什么时候必须自己写哈希算法
大多数时候,我们不需要自己实现哈希算法。PC上用OpenSSL、mbedTLS或者系统库,一行调用就完事。但一旦目标平台是裸机MCU,尤其是Flash和RAM按KB计算的环境,完整加密库就成了负担。另一个常见场景是安全敏感模块需要代码审计,直接使用经过验证的自包含实现比引入大型依赖更容易通过审查。
我当时的需求很简单:给OTA固件算一个256位摘要,用于校验完整性。SHA256不涉及专利、不需要密钥管理,算法规范公开,适合手写。相比MD5和SHA1,SHA256至少在目前的安全强度上仍然站得住脚。虽然轻量设备用SHA1也能算,但谁知道哪天审计就要求更高级别。既然要动手,就直接做SHA256。
1.2 SHA256的技术特点
SHA256属于SHA-2家族,输出固定32字节(256位),内部处理块大小为512位(64字节)。它和MD5结构相似,都来自Merkle-Damgård结构,但SHA256的摘要更长、轮数更多(64轮 vs MD5的64轮但逻辑不同),安全冗余明显更高。实际使用中,SHA256在固件校验、FOTA升级、密钥派生、证书签名等场景里都是默认选择。
我对C语言实现的基本要求是:纯C89,无动态内存分配,无平台相关头文件,只依赖标准<stdint.h>、<string.h>,最好能直接编入STM32和其他嵌入式环境。
2. SHA256算法核心机制拆解:五个步骤搞懂压缩函数
动手写代码之前,先把算法流程理清楚。SHA256可以拆成五个层级:填充消息、划分块、初始化状态、逐块压缩、输出摘要。其中最核心的就是压缩函数,理解它需要盯住三个位运算函数和W数组的生成。
2.1 三个基础函数:Ch、Maj、Σ0、Σ1
SHA256的压缩函数里有几个位操作函数,全部基于32位无符号整数:
Ch(x, y, z) = (x & y) ^ (~x & z),按x的位选择y或z;Maj(x, y, z) = (x & y) ^ (x & z) ^ (y & z),按多数位决定结果;Σ0(x) = ROTR(x,2) ^ ROTR(x,13) ^ ROTR(x,22);Σ1(x) = ROTR(x,6) ^ ROTR(x,11) ^ ROTR(x,25)。
ROTR就是循环右移,C里用(x >> n) | (x << (32-n))实现。这些函数看起来很机械,但它们是雪崩效应的来源:输入任何一位变化,经过多轮后会让将近一半的输出位翻转。可以理解为加密搅拌机里的叶片,数据每过一轮就被打散一次。
2.2 消息调度:从16个32位字到64个32位字
每个512位数据块被分成16个32位字(大端顺序),记为W[0]到W[15]。压缩函数需要用64个W值,所以从第16个开始要扩展:
W[t] = σ1(W[t-2]) + W[t-7] + σ0(W[t-15]) + W[t-16]其中:
σ0(x) = ROTR(x,7) ^ ROTR(x,18) ^ (x >> 3) σ1(x) = ROTR(x,17) ^ ROTR(x,19) ^ (x >> 10)这里可以看到SHA256用了两组“辅助函数”:压缩轮内的Σ大写字母,调度用的σ小写字母。容易混淆,但代码中一眼就能区分。
2.3 64轮压缩与K常数的直觉解释
64轮压缩的每一轮都做类似这样的更新:
T1 = h + Σ1(e) + Ch(e,f,g) + K[t] + W[t] T2 = Σ0(a) + Maj(a,b,c) h = g; g = f; f = e; e = d + T1; d = c; c = b; b = a; a = T1 + T2;初看是一堆变量左移,本质上是把状态向量(a,b,c,d,e,f,g,h)中的信息混入W数组和64个K常数,反复搅拌。那K常数从哪来?它取自自然数中前64个素数的立方根的小数部分前32位。这样一组看似无规律的常数,避免了哈希结果中可能出现的代数结构。你可以把它们理解为搅拌时添加的固定“盐”,不需要随机性,但要有足够好的分布。
整个压缩函数的具体流程,正文后面结合C代码讲。
3. C代码实现与关键段解析:从上下文结构体到最终摘要
3.1 上下文结构体设计
SHA256的Merkle-Damgård结构天然需要一个上下文对象,记录已处理字节数、当前块缓冲区和8个状态字。C语言实现通常这样定义:
typedef struct { uint32_t state[8]; uint64_t bitlen; uint8_t data[64]; uint32_t datalen; } SHA256_CTX;bitlen记录消息总比特数,最大支持2^64位,足够现实使用。data保存不满64字节的尾部。datalen指示缓冲区里有效字节数。这里的技巧是:不先计算填充再一次性喂入,而是边喂入边累积,等到计算摘要时再统一处理尾部填充。
3.2 初始化函数
初始状态向量的四个值来自前8个素数平方根的小数部分前32位。这个初始化只需要赋值,我写成宏或直接赋值:
void sha256_init(SHA256_CTX *ctx) { ctx->state[0] = 0x6a09e667; ctx->state[1] = 0xbb67ae85; ctx->state[2] = 0x3c6ef372; ctx->state[3] = 0xa54ff53a; ctx->state[4] = 0x510e527f; ctx->state[5] = 0x9b05688c; ctx->state[6] = 0x1f83d9ab; ctx->state[7] = 0x5be0cd19; ctx->bitlen = 0; ctx->datalen = 0; }3.3 压缩函数transform
这是最核心的代码。我采用一次处理一个512位块,把16个字扩展到64个字,然后执行64轮。为了嵌入式环境考虑,我把消息调度数组放在栈上,因为它只有256字节,完全可接受。下面是标准的sha256_transform实现,已经过测试:
static void sha256_transform(SHA256_CTX *ctx, const uint8_t data[]) { uint32_t w[64]; uint32_t i, j; uint32_t a, b, c, d, e, f, g, h, t1, t2; 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 }; // 把大端字节解析成32位字 for (i = 0; i < 16; i++) { w[i] = ((uint32_t)data[i*4] << 24) | ((uint32_t)data[i*4+1] << 16) | ((uint32_t)data[i*4+2] << 8) | ((uint32_t)data[i*4+3]); } for (i = 16; i < 64; i++) { uint32_t s0 = (w[i-15] >> 7) | (w[i-15] << 25); s0 ^= (w[i-15] >> 18) | (w[i-15] << 14); s0 ^= (w[i-15] >> 3); uint32_t s1 = (w[i-2] >> 17) | (w[i-2] << 15); s1 ^= (w[i-2] >> 19) | (w[i-2] << 13); s1 ^= (w[i-2] >> 10); w[i] = s1 + w[i-7] + s0 + w[i-16]; } a = ctx->state[0]; b = ctx->state[1]; c = ctx->state[2]; d = ctx->state[3]; e = ctx->state[4]; f = ctx->state[5]; g = ctx->state[6]; h = ctx->state[7]; for (i = 0; i < 64; i++) { uint32_t S1 = (e >> 6) | (e << 26); S1 ^= (e >> 11) | (e << 21); S1 ^= (e >> 25) | (e << 7); uint32_t ch = (e & f) ^ ((~e) & g); t1 = h + S1 + ch + k[i] + w[i]; uint32_t S0 = (a >> 2) | (a << 30); S0 ^= (a >> 13) | (a << 19); S0 ^= (a >> 22) | (a << 10); uint32_t maj = (a & b) ^ (a & c) ^ (b & c); t2 = S0 + maj; h = g; g = f; f = e; e = d + t1; d = c; c = b; b = a; a = t1 + t2; } ctx->state[0] += a; ctx->state[1] += b; ctx->state[2] += c; ctx->state[3] += d; ctx->state[4] += e; ctx->state[5] += f; ctx->state[6] += g; ctx->state[7] += h; }旋转用的是>>和<<组合,没有额外定义宏,方便直接读代码。注意常量数组k[64]放在函数内部,每次调用都会在栈上创建。如果追求极致性能,可以把k声明为static const,避免在栈上重复初始化。我实际项目中已经改成static const,但在上面的代码里为了演示清晰,还是常规写法。
3.4 update函数与最终final函数
sha256_update负责把外部数据填入缓冲区,满64字节就调用一次transform。常见的写法如下:
void sha256_update(SHA256_CTX *ctx, const uint8_t *data, size_t len) { size_t i; for (i = 0; i < len; i++) { ctx->data[ctx->datalen] = data[i]; ctx->datalen++; if (ctx->datalen == 64) { sha256_transform(ctx, ctx->data); ctx->bitlen += 512; ctx->datalen = 0; } } }这里有一个不太优雅的地方:每次只能处理一个字节,如果输入是几MB的固件,循环次数太多。更好的做法是先把已有缓冲填满,再按64字节大块处理。我给出的版本是教学向,性能在嵌入式够用,但PC上测速会偏慢。后面性能优化章节会展开讲高效版。
sha256_final需要处理最后的填充和长度字段。规则是:先补一个0x80,然后补0x00直到数据长度模512等于448,最后8字节写入消息总比特数(大端序)。注意:这里的长度是在填充之前的数据长度。
void sha256_final(SHA256_CTX *ctx, uint8_t hash[32]) { uint32_t i; uint64_t bitlen = ctx->bitlen + ((uint64_t)ctx->datalen * 8); ctx->data[ctx->datalen] = 0x80; ctx->datalen++; if (ctx->datalen > 56) { while (ctx->datalen < 64) ctx->data[ctx->datalen++] = 0; sha256_transform(ctx, ctx->data); ctx->datalen = 0; } while (ctx->datalen < 56) ctx->data[ctx->datalen++] = 0; // 写入64位长度,大端 for (i = 0; i < 8; i++) { ctx->data[56 + i] = (uint8_t)(bitlen >> (56 - i*8)); } sha256_transform(ctx, ctx->data); for (i = 0; i < 8; i++) { hash[i*4] = (uint8_t)(ctx->state[i] >> 24); hash[i*4+1] = (uint8_t)(ctx->state[i] >> 16); hash[i*4+2] = (uint8_t)(ctx->state[i] >> 8); hash[i*4+3] = (uint8_t)(ctx->state[i]); } }这里最容易被忽略的就是bitlen的计算:ctx->bitlen保存的是已经处理过的块的比特数,在sha256_update中每处理一块加512;而ctx->datalen是当前缓冲区字节数。两个加起来才是总比特数。如果直接把ctx->datalen * 8加到bitlen上,需要格外小心:ctx->bitlen是64位,datalen * 8是32位,乘法前最好显式转成uint64_t,否则在16位或32位平台上可能溢出。
4. 标准测试向量与边界用例:让实现经受官方验证
4.1 三个官方标准测试向量
我不信任打印出来的“看起来正确”的哈希。写完后要用官方测试向量验证。
- 空串:
sha256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 - 字符串
"abc":ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad - 字符串
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq":248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1
跑一下如果这三个都对,基本核心算法正确。我实现时,第一遍跑"abc"就通过。但空串和长串也都测过,因为空串会走完整的填充分支,而长串会走多次压缩。
4.2 分块喂入与一次性喂入的一致性
另一个重要测试是:把同一段数据分成两段喂入,再和一次喂入的结果比较。比如对一段1000字节数据,分别用update(data, 1000)和update(data, 555); update(data+555, 445),两次摘要必须完全一致。这个测试主要验证datalen缓冲逻辑和填充逻辑是否健壮。我写了个小测试程序,循环生成长度从0到1024的随机数据,随机切分成若干片喂入,所有结果与OpenSSL对照。这比只测三个向量更能暴露问题。
4.3 边界条件:0长度、刚好64字节、刚好56字节
几个容易出错的边界:
- 输入0字节:
final时datalen=0,需要先补0x80,再填充到56,最后写长度。 - 输入56字节:恰好在
final中datalen=56,此时不需要额外块,直接补长度。 - 输入64字节:调用一次transform后
datalen=0,final时再进入0字节分支。 - 输入55字节:填充后正好达到56,后面直接写长度;
datalen=56。 - 输入56字节和输入57字节:后者会导致
datalen=57,final要先把当前块补齐到64并压缩,然后额外处理一个块。
这些情况,最好写一个循环测试,从长度0跑到1000,逐一验证。集成到CI里,以后改代码也不怕。
5. 踩坑实录:字节序、长度填充和内存对齐的三个深坑
5.1 大端小端混淆:为什么低字节在前的机器上摘要全错
SHA256标准规定所有多字节整数都必须按大端序解释。也就是说,同一个512位块,在x86小端机器上解析时要手动把字节拼成uint32_t,而不是直接内存强转。我看到过一份代码直接memcpy到uint32_t数组,结果在x86上跑出的摘要和官方完全不一致,因为x86内存中整数字节序是反的。
解决方法就是我在transform里写的拼接方式:
w[i] = ((uint32_t)data[i*4] << 24) | ((uint32_t)data[i*4+1] << 16) | ((uint32_t)data[i*4+2] << 8) | ((uint32_t)data[i*4+3]);这种方式可移植性最好,不依赖平台字节序。输出摘要时也是同理,逐个字节从uint32_t中提取高字节到低字节。
5.2 64位长度字段的移位陷阱
写入64位比特长度时,如果直接写bitlen >> 56这类移位,在8位单片机上是低效的,但更容易犯的错误是忘记bitlen是uint64_t,导致移位超过32位时只截断了低32位。比如这样写:
ctx->data[56] = (uint8_t)(ctx->bitlen >> 56); // 前提是ctx->bitlen是uint64_t如果你的上下文里bitlen被声明成32位,那长度超过512MB(2^32 bit = 512MB)之后摘要就会出错。所以,消息长度字段至少要是64位,这是硬性要求。在PC上可以跑一个100MB数据测试,看看结果是否和OpenSSL一致,很多实现会在这类测试中现形。
5.3 缓冲区与const修正:一次80字节溢出事故
我在第一版代码中,sha256_final里没有处理好datalen在溢出后的清零。当输入数据恰好满了64字节且还有尾部数据时,final阶段可能访问未初始化的缓冲区,或者覆盖掉相邻内存。具体场景是:update处理完最后一块后,datalen被清零,final补位时在data[0]写0x80,这些都没问题。但如果你在update后忘记给ctx->datalen清零,就会出现双重填充。
另外,data数组是64字节,补位时datalen++可能变成65,写入data[64]就越界了。我最初检查的是if (ctx->datalen > 56),如果datalen=64,说明上一轮没清零,就会出问题。正确做法是在每次transform后清零datalen,且final开头可以从datalen最大56开始处理,避免越界。
5.4 无符号溢出是特性,不是BUG
SHA256内部大量使用32位无符号整数相加,溢出是预期行为,C标准对无符号整型的回绕有定义。千万不要为了消除编译器的-Woverflow警告,把加法改成uint64_t再转回,那样不仅性能差,而且会破坏算法正确性。只要类型是uint32_t,溢出后的低位结果就是算法需要的。我刚学这个算法时,试图用uint64_t保存中间值,结果摘要完全不对,就是因为溢出的截断时机变了。
6. 性能优化与验证建议:把哈希速度再提一档
6.1 编译器优化选项
手写代码在默认优化级别下可能很慢。在测试性能前,至少开启-O2。我的经验是:-O2比-O0速度快4到5倍,-O3在循环展开上可能再快10%左右。如果嵌入式编译器支持-Os,也能接受。注意不要开启严格别名规则时使用不安全的类型强转,最好保持可移植的字节拼接写法。
6.2 大块处理与循环展开
最简单的性能优化是减少逐字节update的循环开销。改进后的update应该先把已有缓冲填满,再处理完整块。还可以在主循环里用while (len >= 64),一次处理一个块,减少函数调用次数。transform内部的64轮循环,可以手动展开成几组,减少循环控制开销。但展开后代码冗长、可读性差,我倾向于交给编译器在-O3下处理。
另一个技巧是使用__builtin_bswap32将字节序转换改成一条指令。不过在移植性优先的项目里不推荐。若平台是ARM Cortex-M系列,__builtin_bswap32能帮你省掉不少位操作。
6.3 与OpenSSL的随机测试框架
我建了一个简单的测试工具:从/dev/urandom(或者用伪随机数)生成随机长度数据,分片喂入自己的实现和OpenSSL的SHA256(),然后比对结果。每次构建后跑一遍,能快速捕捉回归问题。代码也很简单,核心就是调用两种实现然后memcmp。这比只跑官方测试向量更让人放心。
在实际项目中,我还把这份SHA256代码用在Bootloader从Flash读取固件计算摘要的场景。只要在编译时关掉动态内存分配,它占用的RAM只有上下文结构体(约120字节)加栈上w[64](256字节),总共不到400字节,对MCU非常友好。最终Bootloader固件体积增加了大约2KB,完全可接受。
如果你也想拿这份代码做二次开发,建议先把sha256_transform里的k常量表改成static const,把update的大块处理逻辑加上。然后跑完测试向量和随机测试,就基本不会出错了。哈希算法是个严谨的数学过程,只要规范实现,剩下的就是调性能和防手滑。
本文还有配套的精品资源,点击获取