
简介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-2561.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 变成 gg 变成 ff 变成 ee 变成 d T1d 变成 cc 变成 bb 变成 aa 变成 T1 T2。这里 W[t] 是消息调度值。前 16 个 W 直接来自当前 64 字节块的 16 个 32 位字后 48 个 W 由前面已有的 W 通过 σ0、σ1 和加法推导出来。这样每一轮的计算都依赖更多历史信息消息的任何微小变化都会迅速扩散到所有寄存器最终产生雪崩效应。K[t] 是 64 个固定常量它们来自前 64 个质数的立方根小数部分起到打乱规律的作用。3. 工程落地手把手写一个可运行的 SHA-2563.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 摘要空字符串 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855abcba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015adhello worldb94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde956字节串 abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1验证方法很简单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本文还有配套的精品资源点击获取