news 2026/8/31 15:39:05

C++17实现纯手写AES算法:从GF(2^8)到ECB/CBC分组模式

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
C++17实现纯手写AES算法:从GF(2^8)到ECB/CBC分组模式

简介:本资源是一份面向C++初学者与信息安全入门者的简易AES加解密算法实现项目,聚焦密码学核心原理与工程落地结合,解决学习者在理解对称加密机制、动手实现标准算法时缺乏可运行参考代码的痛点。压缩包共4个文件(325KB),含核心实现代码(main.cpp)、原理与步骤详解的PDF文档、简洁易读的Markdown说明及开源许可证文件,覆盖从S盒查表、密钥扩展、轮函数到加解密全流程的C++编码实践。已有552人学习下载,代码采用std::vector<uint8_t>规范处理字节块,清晰分离SubBytes、ShiftRows、MixColumns等关键操作,辅以注释详尽的流程说明,便于读者逐轮调试、验证中间状态并深入理解AES128的加密逻辑与逆向解密结构。 最近花了一个周末,把AES从理论到代码完整手撸了一遍,整个过程比想象中更有意思。这个项目不复杂,就是用纯C++(C++17标准)实现了一个不依赖OpenSSL、Crypto++等任何第三方库的简易AES加解密算法,支持AES-128/192/256三种密钥长度,同时实现了ECB和CBC两种分组模式,填充方式采用PKCS7。做这件事的动机有两个:一是想彻底搞清楚AES内部那几个看起来神神秘秘的轮变换到底在做什么,而不是永远停留在“调用一下库函数”的黑盒层面;二是很多嵌入式设备、教学项目、课程设计场景里确实不方便引入重型加密库,一个结构清晰、拿来即用的C++版本代码会非常实用。这篇博文适合三类读者:马上要交课程设计的学生、工作中遇到加密需求但不想直接黑盒调库的工程师,以及单纯想把“AES很神秘”这层窗户纸捅破的程序员。

我会从整体设计思路、AES核心原理、C++代码实现、测试向量验证、常见坑位这五个方向展开,尽量把每个环节的“为什么”讲清楚。

1. 项目背景与整体设计思路

1.1 为什么放着现成的加密库不用,要自己写一个

很多朋友看到“自己实现AES”的第一反应是:何必呢,OpenSSL一行代码就解决了。这话在工程实践上没问题,但用在“搞懂原理”这个目标上就不成立了。我见过太多人用了好几年的AES,却分不清SubBytes和ShiftRows分别负责什么,也说不清楚为什么解密顺序和加密顺序不一样。

自己写一个简易实现的真正价值在于:你必须把每一个字节的流向都搞清楚,必须理解GF(2^8)上的乘法为什么不能用整数乘法代替,必须弄明白密钥扩展里的Rcon轮常量到底起什么作用。当你亲手把这些代码跑通,拿到和标准测试向量一致的结果时,那层窗户纸才算真正捅破。

另外还有一个现实场景:在资源受限的嵌入式环境、实验教学平台或者某些不能随意引入第三方依赖的系统中,一个精简的AES实现可以直接编译运行,不依赖外部库的版本匹配问题。这个项目的定位就是“教学可用、工程可移植、代码易读”,所以我没有做太多极致优化,而是优先保证逻辑清晰。

1.2 模块划分与支持范围:从ECB到CBC

AES本身只定义了单块加密算法,也就是把16字节明文变成16字节密文。但实际使用中,数据往往超过16字节,这时候就需要“分组模式”来决定多块数据之间的关联方式。

我实现了两种模式:ECB和CBC。ECB模式最简单,每一块独立加密,块与块之间没有关联,所以我把它作为底层单块加密的验证入口。CBC模式则在加密前把当前明文块和上一个密文块做异或,这样相同的明文块在不同位置会得到不同的密文块,安全性明显更好。实际工程中CBC用得远比ECB多,但ECB便于测试和入门理解,两个都实现可以很好地对比出分组模式的意义。

填充方式采用PKCS7。AES分组长度固定16字节,最后一组不足16字节时必须填充。PKCS7规则简单直接:缺几个字节就填几,比如缺5个字节就填5个0x05。如果数据刚好是16的倍数,还要额外填充一整块16个0x10,否则解密时无法区分“数据末尾碰巧是0x01”和“真实填充了0x01”这两种情况。

1.3 代码结构怎么组织才清晰

我采用了一个AES类来封装整个算法,类的内部大体分成三个层次。

底层是GF(2^8)有限域运算,包括xtime倍乘、gfMul乘法、S盒生成、逆S盒生成。这一层不依赖任何类状态,全是纯函数。中层是十六轮变换的基本动作,包括SubBytes、ShiftRows、MixColumns、AddRoundKey以及它们各自的逆操作。上层才是对外接口:密钥扩展、单块加解密、分组模式的组合与填充逻辑。

这样分层的好处很明显:出问题时可以一层一层定位。加密结果不对,先查单块加密,单块不对就逐步打印每一轮的state矩阵,和FIPS-197文档里的中间值比对。如果单块加密是对的但整体结果不对,问题一定出在分组模式或填充上,不用回头怀疑轮变换写错了。

2. 先弄懂AES的数学地基:从GF(2^8)到四个轮变换

2.1 状态矩阵和字节序:一个容易被忽略的坑

AES加解密的单位是128位,也就是16字节。这16字节不是简单地当作一个数组去处理,而是要排列成一个4x4的状态矩阵。关键点在于,输入字节是按“列优先”顺序填入矩阵的。

举个例子,输入十六进制序列:00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f,对应的状态矩阵是:

00 04 08 0c 01 05 09 0d 02 06 0a 0e 03 07 0b 0f

也就是说,前四个字节填第0列,接下来的四个字节填第1列。很多初学者第一次写AES,就是在这一步栽的跟头:按照“行优先”填矩阵,后续所有轮变换全部错位,最后加密结果和标准向量对不上。

我自己在代码里用state_[r][c]表示第r行第c列,输入时写in[r + 4c],输出时用out[r + 4c],从代码上就固定了列优先的时空一致性。这个细节看着小,但真的能省掉大量排查时间。

2.2 SubBytes、S盒与乘法逆元:唯一一处非线性

SubBytes是AES中唯一引入非线性的步骤。这个名字翻译过来就是“字节代换”,每一个状态字节通过查S盒表变成另一个字节。S盒是一个256字节的查找表,不是随便拍脑袋生成的,它的构造过程包含两个步骤。

第一步,在GF(2^8)有限域中求字节的乘法逆元。所谓GF(2^8),可以理解成一个特殊的“字节运算规则集合”,加法和减法都等价于异或运算,乘法是基于一个不可约多项式x^8 + x^4 + x^3 + x + 1的模运算。乘以2有一个非常高效的实现,叫xtime:先把字节左移一位,如果最高位是1就再异或0x1B。这个操作在MixColumns里也会反复用到,是整个AES的数学基石。

第二步,对乘法逆元做仿射变换,一个包含循环左移和异或0x63的位级变换。加这个仿射变换的目的是防止S盒存在过于简单的代数结构,增加抗数学攻击的能力。不用被这些术语吓到,实现层面就是几张表、几个异或和移位操作。

我生成S盒的代码用了暴力查找乘法逆元的方式:对每个字节遍历0到255,找到哪个数和它相乘等于1。这个方法对于初始化一次、后续只查表的场景完全够用,而且逻辑直观,适合学习。追求性能的话可以改成扩展欧几里得算法,但那不是这个项目的重点。

2.3 ShiftRows与MixColumns:扩散性的来源

如果说SubBytes负责“混淆”,那ShiftRows和MixColumns就是负责“扩散”的。混淆让字节和密钥之间的关系变得极其复杂,扩散则让明文的一个比特变化尽可能快地影响到整个密文。

ShiftRows的操作非常直观:状态矩阵的第0行不动,第1行循环左移1个字节,第2行循环左移2个字节,第3行循环左移3个字节。解密的时候反过来,变成循环右移。这一步的作用是让不同列之间的数据开始交换,打破列间的独立性。

MixColumns就更有意思了。它把每一列的4个字节看成GF(2^8)上的多项式,左乘一个固定的矩阵。直观理解就是做了一次“列内混合”:新列的每个字节都由旧列所有字节按不同权重组合而成。加密时用的权重是2、3、1、1,解密时用的逆矩阵权重是14、11、13、9。

如果你第一次写解密函数,最容易犯的错误就是把解密轮变换的顺序完全写成加密的逆序。实际操作中,解密每一轮先做InvShiftRows,再做InvSubBytes,然后AddRoundKey,最后做InvMixColumns,这个顺序和加密轮的顺序并不是完全镜像的。原因是AddRoundKey是自逆操作,它可以和InvMixColumns交换位置,标准文档里给出的解密流程是调整后的等价形式。

2.4 密钥扩展:Rcon到底在干什么

AES每一轮都要使用不同的轮密钥,16字节的初始密钥需要被扩展成(Nr+1)组轮密钥。比如AES-128有10轮,就需要11组轮密钥,总共176字节。这个扩展过程叫Key Expansion。

扩展算法的核心思路是:把密钥看成4字节一组的字,后续每个字由前面某个字和更早的字异或得到。每隔Nk个字(Nk是密钥长度除以32)就会触发一个特殊处理:先循环左移一个字节,再对每个字节查S盒,最后和轮常量Rcon异或。

Rcon轮常量是个一维数组,第1个是0x01,后面每一项都是前一项在GF(2^8)上乘以2,也就是反复做xtime操作:0x01, 0x02, 0x04, 0x08, 0x10, 0x20...。为什么需要这个常数?就是为了破坏密钥扩展中的对称性,避免不同轮次之间的密钥出现可预测的关联。如果去掉Rcon,整个轮密钥会表现出很强的规律性,会极大削弱安全性。

还有一点要注意:AES-256的密钥扩展规则和AES-128、AES-192不完全一样。在AES-256中,每隔Nk个字,也就是每处理一组新字时,除了常规的Rcon处理之外,在字下标i模Nk等于4的时候还要额外做一次SubWord,也就是把每个字节过一遍S盒。这个细节很容易被忽略,我在做AES-256支持的时候就在这里吃过亏,后面会详细说。

3. C++实现:从S盒生成到完整加解密函数

3.1 数据结构设计和S盒生成

先看一下类的设计。为了可读性和移植性,状态矩阵直接用嵌套数组表示,密钥扩展结果用字数组保存。

#include <array> #include <cstdint> #include <cstring> #include <iostream> #include <stdexcept> #include <string> #include <vector> using Byte = uint8_t; // 无符号8位整数 using Word = uint32_t; // 无符号32位整数 class AES { public: enum class Mode { ECB, CBC }; AES(const std::vector<Byte>& key, Mode mode, const std::vector<Byte>& iv = {}) : mode_(mode), iv_(iv) { if (key.size() != 16 && key.size() != 24 && key.size() != 32) { throw std::invalid_argument("key must be 16/24/32 bytes"); } if (mode == Mode::CBC && iv.size() != 16) { throw std::invalid_argument("iv must be 16 bytes"); } Nk_ = static_cast<int>(key.size() / 4); Nr_ = Nk_ + 6; sbox_ = generateSbox(); inv_sbox_ = generateInvSbox(sbox_); key_.assign(key.begin(), key.end()); expandKey(); } std::vector<Byte> encrypt(const std::vector<Byte>& plain); std::vector<Byte> decrypt(const std::vector<Byte>& cipher); private: Mode mode_; std::vector<Byte> iv_; std::vector<Byte> key_; std::array<std::array<Byte, 4>, 4> state_{}; std::array<Word, 60> roundKeys_{}; std::array<Byte, 256> sbox_{}; std::array<Byte, 256> inv_sbox_{}; int Nk_ = 0; int Nr_ = 0; void expandKey(); void encryptBlock(const Byte* in, Byte* out); void decryptBlock(const Byte* in, Byte* out); std::vector<Byte> pkcs7Pad(const std::vector<Byte>& data); std::vector<Byte> pkcs7Unpad(const std::vector<Byte>& data); };

这里我把全部密钥扩展结果放在一个60字的数组里,最大情况下AES-256需要15轮轮密钥,一共60个字,提前分配好避免使用动态内存。状态矩阵使用4x4嵌套数组,行和列的语义在代码里一目了然。

S盒生成是整个实现中最有“数学味”的部分。核心是先求GF(2^8)乘法逆元,再做仿射变换,代码实现如下:

namespace { Byte xtime(Byte a) { Byte r = static_cast<Byte>(a << 1); if (a & 0x80) { r ^= 0x1B; } return r; } Byte gfMul(Byte a, Byte b) { Byte p = 0; for (int i = 0; i < 8; ++i) { if (b & 1) { p ^= a; } a = xtime(a); b >>= 1; } return p; } std::array<Byte, 256> generateSbox() { std::array<Byte, 256> sbox{}; for (int i = 0; i < 256; ++i) { Byte inv = 0; if (i != 0) { for (int j = 1; j < 256; ++j) { if (gfMul(static_cast<Byte>(i), static_cast<Byte>(j)) == 1) { inv = static_cast<Byte>(j); break; } } } Byte x = inv; Byte v = x ^ static_cast<Byte>((x << 1) | (x >> 7)) ^ static_cast<Byte>((x << 2) | (x >> 6)) ^ static_cast<Byte>((x << 3) | (x >> 5)) ^ static_cast<Byte>((x << 4) | (x >> 4)) ^ 0x63; sbox[static_cast<Byte>(i)] = v; } return sbox; } std::array<Byte, 256> generateInvSbox(const std::array<Byte, 256>& sbox) { std::array<Byte, 256> inv{}; for (int i = 0; i < 256; ++i) { inv[sbox[i]] = static_cast<Byte>(i); } return inv; } } // namespace

我生成逆S盒的方式不是重复一遍逆计算,而是利用S盒做反向映射。因为S盒是可逆置换,sbox[i] = v,那么inv_sbox[v] = i。这个思路比直接求代码要清爽很多,也更不容易出错。

3.2 轮变换的C++实现细节

SubBytes和ShiftRows都比较直白。SubBytes就是把状态矩阵的每个字节替换成S盒中的对应值。ShiftRows的关键是正确处理循环移位的方向,加密时第r行第c列的新值来自第r行第(c+r)列,解密反过来即可。

void shiftRows() { std::array<std::array<Byte, 4>, 4> tmp = state_; for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { state_[r][c] = tmp[r][(c + r) % 4]; } } } void invShiftRows() { std::array<std::array<Byte, 4>, 4> tmp = state_; for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { state_[r][c] = tmp[r][(c - r + 4) % 4]; } } }

MixColumns每一列的处理完全独立,我直接针对每一列取出四个字节,再做GF(2^8)乘法矩阵组合。加密用2、3、1、1矩阵,解密用14、11、13、9矩阵。

void mixColumns() { for (int c = 0; c < 4; ++c) { Byte s0 = state_[0][c]; Byte s1 = state_[1][c]; Byte s2 = state_[2][c]; Byte s3 = state_[3][c]; state_[0][c] = gfMul(0x02, s0) ^ gfMul(0x03, s1) ^ s2 ^ s3; state_[1][c] = s0 ^ gfMul(0x02, s1) ^ gfMul(0x03, s2) ^ s3; state_[2][c] = s0 ^ s1 ^ gfMul(0x02, s2) ^ gfMul(0x03, s3); state_[3][c] = gfMul(0x03, s0) ^ s1 ^ s2 ^ gfMul(0x02, s3); } }

这里gfMul直接复用了前面用于S盒生成的GF(2^8)乘法函数。一开始我还担心性能不够,但实测下来,对几百字节的数据做加解密是完全感知不到延迟的。真要处理海量数据,再考虑用查表法优化多项式乘法也不迟。

AddRoundKey的实现要格外注意字节序。我的roundKeys_数组是按Word存储的,每个Word的四个字节从高到低对应状态矩阵的0到3行。所以取第round轮第c列密钥的第r个字节时,要右移24 - 8 * r位。完整代码如下:

void addRoundKey(int round) { for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { Word w = roundKeys_[round * 4 + c]; Byte kb = static_cast<Byte>((w >> (24 - 8 * r)) & 0xFF); state_[r][c] ^= kb; } } }

3.3 密钥扩展的具体实现

密钥扩展遵循标准流程。先把原始密钥按每4字节一组拆成Word,大端字节序。然后从第Nk个字开始递增生成,每隔Nk个字触发一次Rcon处理,AES-256还要在特定条件多一次SubWord。

void AES::expandKey() { int totalWords = 4 * (Nr_ + 1); Word words[60] = {}; for (int i = 0; i < Nk_; ++i) { words[i] = (static_cast<Word>(key_[4 * i]) << 24) | (static_cast<Word>(key_[4 * i + 1]) << 16) | (static_cast<Word>(key_[4 * i + 2]) << 8) | static_cast<Word>(key_[4 * i + 3]); } Word rcon = 0x01000000; for (int i = Nk_; i < totalWords; ++i) { Word temp = words[i - 1]; if (i % Nk_ == 0) { temp = subWord(rotWord(temp)) ^ rcon; rcon = static_cast<Word>(xtime(static_cast<Byte>(rcon >> 24))) << 24; } else if (Nk_ > 6 && i % Nk_ == 4) { temp = subWord(temp); } words[i] = words[i - Nk_] ^ temp; } for (int i = 0; i < totalWords; ++i) { roundKeys_[i] = words[i]; } }

这个rcon动态更新的写法特别省事。不用提前准备整个轮常量表,每遇到一次Rcon处理就把当前值用xtime乘一下,得到下一轮要用的值。xtime是对rcon的最高字节做倍乘,然后再左移24位回到原来的位置。这个操作从1开始,依次得到2、4、8、16、32,对应标准文档里的RC数组。

一开始写AES-256时我没注意到那个“多一次SubWord”的坑。标准文档明确写着:当密钥长度是256位且i对Nk取模等于4时,temp要先做SubWord再做异或。我漏掉这个条件后,用AES-256加密单块得到的结果和测试向量不一致,排查了很久才发现。这个细节很坑,但也让我真正记住了AES-256和AES-128密钥扩展的差异。

3.4 单块加密解密的分步实现

单块加密的流程严格按照FIPS-197文档:存初始密钥,然后循环前Nr-1轮依次做SubBytes、ShiftRows、MixColumns、AddRoundKey,最后一轮略过MixColumns。

void AES::encryptBlock(const Byte* in, Byte* out) { for (int r = 0; r < 4; ++r) { for (int c = 0; c < 4; ++c) { state_[r][c] = in[r + 4 * c]; } } <p> <a href="https://download.csdn.net/download/s1t16/88041004" style="color:#ec7500;font-size:14px;"> 本文还有配套的精品资源,点击获取 </a> <img alt="menu-r.4af5f7ec.gif" src="https://csdnimg.cn/release/wenkucmsfe/public/img/menu-r.4af5f7ec.gif" style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;"> </p>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/31 15:38:59

51单片机锂电池检测仪与BMS设计:原理、算法到仿真实现

简介&#xff1a;本资源面向电子类专业学生、嵌入式初学者及电池管理系统&#xff08;BMS&#xff09;实践开发者&#xff0c;提供一套覆盖锂电池检测、电量估算、充放电保护与均衡管理的完整51/52单片机工程实践方案。资源包含4套相互关联又功能侧重不同的设计&#xff1a;电压…

作者头像 李华
网站建设 2026/8/31 15:38:56

自动化实战:使用Python+pyautogui自动登录B站,自动化操作就是如此简单!

鉴于上述所提及的问题, 在周末时光, 我撰写了一个借助加法运算“”来达成B站自动登录流程的内容。该流程主要涵盖了这些方面, 先是返回至桌面, 接着获取坐标, 随后启动浏览器, 再输入网址, 之后点击登录按钮, 然后输入账号密码, 最后进行登录操作。1&#xff09;坐标定位工具, …

作者头像 李华
网站建设 2026/8/31 15:38:50

Grok Build v1.0.12升级指南:先验证兼容性,再跑批量任务

Grok Build 更新到 v1.0.12 了。从 v1.0.7 上线&#xff0c;到 v1.0.9 发布&#xff0c;再到现在这个版本&#xff0c;迭代节奏不算慢。但版本号连续跳动&#xff0c;不代表每个新版本都值得立刻升级&#xff0c;更不代表所有人的使用方式都要跟着改。 如果你最近看到“Grok B…

作者头像 李华
网站建设 2026/8/31 15:34:19

天堂1服务端LINGM管理端资源包解析:从部署到排障

简介&#xff1a;本资源是一款面向《天堂1》&#xff08;Lineage 1&#xff09;老服玩家与客户端修改爱好者的Linux兼容型辅助工具包&#xff0c;聚焦于Lin.bin v13032701版本的适配与数据管理&#xff0c;解决游戏客户端定制、运行环境优化及核心配置文件维护等实际问题。压缩…

作者头像 李华
网站建设 2026/8/31 15:34:06

IndexTTS2零样本音色克隆TTS本地部署实战指南

这次我们来看一个最近热度上升很快的开源 TTS 项目&#xff1a;IndexTTS2。如果你之前被 GPT-SoVITS 的微调流程、音色数据准备、多步训练折腾过&#xff0c;那 IndexTTS2 这条路线可能会让你省不少事。 IndexTTS2 是 Bilibili Index Team 开源的中英双语端到端 TTS 模型&…

作者头像 李华