资讯动态

嵌入式AES128轻量实现:ECB与CBC模式纯C手写方案

发布时间:2026/10/21 14:15:51 来源:尧图企业网站定制
简介本资源是一套面向嵌入式开发工程师与单片机初学者的AES128对称加密实战代码聚焦ECB与CBC两种核心工作模式的C语言轻量级实现解决资源受限平台下安全通信基础模块缺失问题。压缩包仅含2个精简文件1个C源文件1个头文件总大小5KB结构清晰、无依赖库可直接集成到STM32、ESP32等常见MCU工程中其中.c文件封装了密钥扩展、加解密主流程及PKCS7Padding自动填充/去除逻辑.h文件提供标准接口声明与配置宏便于快速调用与二次定制。已有7331人学习下载说明其在嵌入式安全入门场景中具备较强实用性与验证基础。读者可直接获取可运行的完整算法实现掌握ECB的块独立性特征、CBC中IV引入机制与链式异或原理并通过源码深入理解嵌入式环境下内存占用优化、无动态分配设计及填充边界处理等关键实践细节。1. 为什么在资源受限的单片机上还非得手撸 AES128 的 ECB 和 CBC 模式不是为了炫技而是因为某嵌入式项目里通信模块必须在不引入任何第三方加密库、不依赖操作系统、RAM 不足 4KB、Flash 剩余空间仅 16KB 的硬约束下完成设备身份密文校验与配置参数安全下发。AES128-ECB 看似“不安全”但在设备唯一密钥预置、明文固定长度如 16 字节设备 ID、无重放风险的离线认证场景中它反而是最轻量、最可控的选择而 CBC 模式则用于后续 OTA 配置块的分组加密必须支持 PKCS#7 填充——这意味着你不能直接抄 OpenSSL 的代码也不能用 CMS 或 mbedtls 的全量实现。这份 C 语言方案就是为这种真实嵌入式现场写的无动态内存分配、无浮点运算、无函数指针表、所有查表内联展开、密钥和 IV 全局静态声明、加解密函数可重入且栈开销恒定 ≤ 128 字节。适合做毕设/课设的同学快速验证密码学原理也适合工程师直接集成进 STM32F0、GD32E230、ESP32-S2 等 Cortex-M0/M3 芯片的 Bootloader 或安全子系统中。2. 总体架构设计三层结构与四个核心模块的职责划分本方案采用清晰的三层结构接口层 → 算法层 → 底层支撑层完全规避了传统密码库中常见的状态机管理、上下文对象、回调注册等嵌入式不友好设计。所有功能通过纯函数暴露无全局副作用便于单元测试与静态分析。四个核心模块严格解耦编译时可按需裁剪aes_core.c/hAES-128 轮函数实现SubBytes/ShiftRows/MixColumns/AddRoundKey含完整 S-Box 查表256 字节 const 数组与逆 S-Box全部使用 uint8_t 运算避免位宽隐式转换padding_pkcs7.c/hPKCS#7 填充与去填充逻辑支持任意长度明文≤ 65535 字节返回填充后长度并原地修改缓冲区不额外申请内存mode_ecb.c/hECB 加解密封装强制要求输入长度为 16 字节整数倍否则返回错误码内部调用aes_encrypt_block()/aes_decrypt_block()单块处理mode_cbc.c/hCBC 加解密封装支持用户传入 IV16 字节加解密过程严格遵循 NIST SP 800-38A 标准加密时先异或 IV 再加密首块解密时先解密再异或前一块密文首块异或 IV。提示所有.c文件均未包含stdio.h、stdlib.h或string.h仅依赖stdint.h和stdbool.h符合 IAR/Keil/ARM GCC 嵌入式工具链默认配置。2.1 密钥调度实现如何在 128 字节 RAM 内完成 11 轮密钥扩展密钥扩展是 AES 实现中最易翻车的环节——很多开源代码直接 malloc 44 字11 轮 × 4 字的轮密钥数组这在无堆环境会直接报错。本方案采用静态轮密钥缓存 按需计算策略// aes_core.h #define AES_ROUNDS 10 #define AES_KEY_WORDS 4 #define AES_EXPANDED_KEY_WORDS ((AES_ROUNDS 1) * AES_KEY_WORDS) // 44 typedef struct { uint32_t words[AES_EXPANDED_KEY_WORDS]; // 全局静态分配占 176 字节 } aes_key_schedule_t; // aes_core.c void aes_key_setup(const uint8_t key[16], aes_key_schedule_t *ks) { // 第0轮直接拷贝原始密钥4个uint32_t for (int i 0; i AES_KEY_WORDS; i) { ks-words[i] __builtin_bswap32(*(const uint32_t*)(key i*4)); } // 后续轮密钥逐轮生成复用 ks-words 前4个位置作临时寄存器 uint32_t temp; for (int r 1; r AES_ROUNDS; r) { temp ks-words[(r-1)*4 3]; // RotWord SubWord Rcon temp ((temp 8) | (temp 24)) 0xFFFFFFFFU; temp sbox[(temp 24) 0xFF] 24 | sbox[(temp 16) 0xFF] 16 | sbox[(temp 8) 0xFF] 8 | sbox[temp 0xFF]; temp ^ rcon[r]; ks-words[r*4 0] ks-words[(r-1)*4 0] ^ temp; ks-words[r*4 1] ks-words[(r-1)*4 1] ^ ks-words[r*4 0]; ks-words[r*4 2] ks-words[(r-1)*4 2] ^ ks-words[r*4 1]; ks-words[r*4 3] ks-words[(r-1)*4 3] ^ ks-words[r*4 2]; } }这段代码的关键设计点在于使用__builtin_bswap32替代手动字节序翻转适配小端 MCU避免htonl等网络函数依赖轮密钥存储为uint32_t数组而非uint8_t[176]提升 CPU 访问效率尤其在 Cortex-M3 的 32 位总线上所有中间变量如temp生命周期严格控制在单轮内编译器可优化进寄存器栈深度恒定rcon[]表仅 11 字节定义为static const uint32_t rcon[11] {0, 0x01000000, 0x02000000, ...}避免运行时计算。2.2 PKCS#7 填充算法为什么不能简单用(16 - len%16)PKCS#7 规范明确要求当明文长度已是 16 字节整数倍时仍需填充 16 字节的 0x10。这是很多初学者实现 CBC 模式失败的根源——解密后去填充时若只检查末字节值是否 ≤16会误将合法明文末字节当作填充长度。本方案严格遵循 RFC 5652// padding_pkcs7.c uint16_t pkcs7_pad(uint8_t *data, uint16_t len, uint16_t max_len) { if (len 0 || max_len len 16) return 0; // 缓冲区不足 uint8_t pad_len 16 - (len % 16); if (pad_len 0) pad_len 16; // 关键整除时补满16字节 uint8_t *pad_start data len; for (uint8_t i 0; i pad_len; i) { pad_start[i] pad_len; } return len pad_len; } bool pkcs7_unpad(const uint8_t *padded, uint16_t padded_len, uint16_t *out_len) { if (padded_len 0 || padded_len % 16 ! 0) return false; uint8_t pad_val padded[padded_len - 1]; if (pad_val 0 || pad_val 16) return false; // 非法填充字节 // 验证最后 pad_val 个字节是否全等于 pad_val for (int i 1; i pad_val; i) { if (padded[padded_len - i] ! pad_val) return false; } *out_len padded_len - pad_val; return true; }逻辑说明pkcs7_pad()返回实际填充后长度调用方据此决定传给cbc_encrypt()的len参数pkcs7_unpad()执行双重校验先验末字节合法性再遍历验证所有填充字节一致性——这是防止填充预言攻击Padding Oracle的基础虽在嵌入式中不常被利用但作为教学/工程规范必须写严out_len为输出参数避免函数返回负值或特殊码符合嵌入式常见 API 风格。2.3 ECB 与 CBC 模式的边界控制如何让编译器帮你发现越界访问ECB 和 CBC 对输入长度要求不同但很多实现把二者混在一个函数里靠 flag 切换极易导致缓冲区溢出。本方案强制分离接口并用static_assert在编译期卡死约束// mode_ecb.h #include assert.h // 注意此处用 assert.h 是为编译期检查实际嵌入式中 assert 可被宏禁用 // 真实项目中建议用自定义编译期断言宏 #define ECB_BLOCK_SIZE 16 // 加密输入必须是16字节整数倍 bool ecb_encrypt(const uint8_t *plaintext, uint8_t *ciphertext, uint16_t len, const aes_key_schedule_t *ks); // 解密同上 bool ecb_decrypt(const uint8_t *ciphertext, uint8_t *plaintext, uint16_t len, const aes_key_schedule_t *ks); // mode_cbc.h bool cbc_encrypt(const uint8_t *plaintext, uint8_t *ciphertext, uint16_t len, const uint8_t iv[16], const aes_key_schedule_t *ks); bool cbc_decrypt(const uint8_t *ciphertext, uint8_t *plaintext, uint16_t len, const uint8_t iv[16], const aes_key_schedule_t *ks);关键设计所有函数签名显式标注const和uint16_t len杜绝int类型隐式转换在mode_ecb.c开头加入#if !defined(__STDC_VERSION__) || __STDC_VERSION__ 201112L #error This module requires C11 or later for static_assert #endif static_assert(ECB_BLOCK_SIZE 16, ECB block size must be 16);确保即使在 Keil MDK-ARM默认 C99下也能触发编译错误倒逼开发者升级标准函数内部第一行即校验if (len % ECB_BLOCK_SIZE ! 0) return false;失败立即返回不执行任何 AES 运算。3. 模块级实现详解从单块加密到 CBC 链式处理本章聚焦两个最易出错的模块aes_encrypt_block()的正确实现以及cbc_encrypt()中 IV 与密文块的时序衔接。这不是教科书推导而是把某次在 GD32E230 上调试三天才定位的时序 bug 拆解成可复现的步骤。3.1 单块 AES 加密为什么 SubBytes 查表必须用 uint8_t 索引AES 的 SubBytes 是非线性替换标准做法是查 256 字节 S-Box 表。但若直接写sbox[data[i]]当data[i]是int类型时在某些编译器如 IAR 8.50下可能因符号扩展导致索引越界如data[i]为0xFF时被解释为-1。本方案强制类型安全// aes_core.c static const uint8_t sbox[256] { 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, /* ... 全256字节 ... */ }; void aes_encrypt_block(const uint8_t in[16], uint8_t out[16], const aes_key_schedule_t *ks) { uint32_t state[4]; // 列优先存储state[0]col0, state[1]col1... // 1. 初始轮密钥加 for (int i 0; i 4; i) { state[i] __builtin_bswap32(*(const uint32_t*)(in i*4)) ^ ks-words[i]; } // 2. 主轮1-9轮 for (int round 1; round 9; round) { // SubBytes: 显式转为 uint8_t 索引 for (int i 0; i 16; i) { uint8_t b ((uint8_t*)state[0])[i]; // 强制取低8位 ((uint8_t*)state[0])[i] sbox[b]; } // ShiftRows、MixColumns、AddRoundKey...略逻辑同上 } // 3. 最终轮无 MixColumns for (int i 0; i 16; i) { uint8_t b ((uint8_t*)state[0])[i]; ((uint8_t*)state[0])[i] sbox[b]; } for (int i 0; i 4; i) { uint32_t tmp state[i] ^ ks-words[round*4 i]; *(uint32_t*)(out i*4) __builtin_bswap32(tmp); } }参数说明in/out必须是 16 字节对齐缓冲区Keil/IAR 默认满足否则*(uint32_t*)可能触发 HardFault__builtin_bswap32是 GCC/Clang 内建函数IAR 需替换为__REV()((uint8_t*)state[0])[i]是安全取字节方式比state[i/4] ((i%4)*8)更直观且不易出错所有for循环使用int而非size_t避免在 16 位 MCU如 MSP430上产生额外指令。3.2 CBC 加密流程IV 如何参与首块运算三步不可逆顺序CBC 模式本质是“链式异或”其正确性极度依赖操作顺序。某次在 STM32F030 上发现加密结果与 Pythonpycryptodome不一致最终定位到 IV 处理顺序错误。标准流程必须是首块明文 ⊕ IV → 得中间值中间值 AES 加密 → 得首块密文后续块明文 ⊕ 前一块密文 → 加密本方案cbc_encrypt()严格按此实现// mode_cbc.c bool cbc_encrypt(const uint8_t *plaintext, uint8_t *ciphertext, uint16_t len, const uint8_t iv[16], const aes_key_schedule_t *ks) { if (len 0 || len % 16 ! 0) return false; uint8_t block[16]; const uint8_t *prev iv; // prev 初始化为 IV for (uint16_t i 0; i len; i 16) { // Step 1: plaintext[i] XOR prev for (int j 0; j 16; j) { block[j] plaintext[i j] ^ prev[j]; } // Step 2: encrypt block if (!aes_encrypt_block(block, ciphertext i, ks)) { return false; } // Step 3: prev current ciphertext block (for next round) prev ciphertext i; } return true; }血泪经验prev必须是指针不能是数组否则每次循环要 memcpyblock定义为局部数组而非static确保多线程/中断安全虽然嵌入式通常单线程但习惯要养加密失败时立即返回false不继续处理后续块——这是调试时快速定位问题块的关键。3.3 CBC 解密流程为什么解密首块后必须再异或 IV解密是加密的逆过程但新手常误以为“解密后直接异或前一块密文”适用于所有块。实际上首块解密后异或的是 IV而非前一块密文因为不存在。本方案用统一逻辑处理bool cbc_decrypt(const uint8_t *ciphertext, uint8_t *plaintext, uint16_t len, const uint8_t iv[16], const aes_key_schedule_t *ks) { if (len 0 || len % 16 ! 0) return false; uint8_t block[16]; const uint8_t *prev iv; for (uint16_t i 0; i len; i 16) { // Step 1: decrypt current ciphertext block if (!aes_decrypt_block(ciphertext i, block, ks)) { return false; } // Step 2: block XOR prev - plaintext for (int j 0; j 16; j) { plaintext[i j] block[j] ^ prev[j]; } // Step 3: prev current ciphertext block (for next round) prev ciphertext i; } return true; }设计说明prev初始指向iv首轮plaintext[0..15] decrypt(cipher[0..15]) XOR iv完全符合标准后续轮次prev自动更新为上一块密文地址无需分支判断block作为临时缓冲区避免直接在plaintext上异或防止明文覆盖影响后续块。4. 常见问题排查嵌入式 AES 实现中 5 个最高频翻车点这些不是理论假设而是某次在客户现场连续 36 小时调试后整理的真实避坑清单。每一条都对应一个曾让 A 同学在答辩前夜崩溃的具体现象。4.1 现象ECB 加密结果与在线工具一致但 CBC 加密后首块密文完全不对原因IV 未按小端序传入或 IV 缓冲区未初始化含随机值解决确认 IV 是 16 字节显式初始化数组且字节序与工具一致。例如 PythonCrypto.Cipher.AES.new(iv, AES.MODE_CBC)默认小端不是大端字节序传输在线工具如 https://www.devglan.com/online-tools/aes-encryption-decryption 使用大端 IV。若 MCU 是小端需手动反转 IVuint8_t iv_fixed[16]; for (int i 0; i 16; i) { iv_fixed[i] iv[15-i]; // 小端 MCU 输入大端 IV 时需反转 } cbc_encrypt(plain, cipher, len, iv_fixed, ks);4.2 现象PKCS#7 去填充后out_len比预期少 16 字节且末字节为0x10原因调用pkcs7_unpad()前未确保padded_len是 16 的整数倍解决CBC 解密后必须先校验长度再调用去填充if (cipher_len % 16 ! 0) { // 错误密文被截断或损坏 return DECRYPT_ERR_LENGTH; } if (!pkcs7_unpad(decrypted, cipher_len, plain_len)) { return DECRYPT_ERR_PADDING; }4.3 现象Keil 编译报错undefined symbol __aeabi_memcpy原因未启用--library_typemicrolib或未链接memcpy实现解决在 Keil 中 Project → Options → Target → Library → Use MicroLIB或直接手写轻量memcpyvoid *my_memcpy(void *dest, const void *src, size_t n) { uint8_t *d (uint8_t*)dest; const uint8_t *s (const uint8_t*)src; while (n--) *d *s; return dest; } // 在所有调用 memcpy 的地方替换为 my_memcpy4.4 现象在 IAR 下加密结果全为0x00但调试发现state数组初始值正常原因IAR 默认开启--debug时__builtin_bswap32被优化掉或行为异常解决关闭调试优化Project → Options → C/C Compiler → Optimization → Level: None或改用 IAR 内建函数// 替换 __builtin_bswap32 为 #define SWAP32(x) __REV(x) // IAR ARM // 或 #define SWAP32(x) __rev(x) // Keil ARMCC4.5 现象同一份密钥STM32F103 加密结果与 ESP32-S2 不一致原因S-Box 查表数组未声明为const导致不同芯片 Flash 映射地址不同或编译器将其放入 RAM解决强制 S-Box 放入 Flash// 在 iar.icf 或 keil scatter file 中添加 // place in ROM_REGION { readonly section .rodata_sbox }; static const uint8_t sbox[256] __attribute__((section(.rodata_sbox))) { ... };并在编译选项中启用--ro_section .rodata_sboxIAR或--section .rodata_sboxARMGCC。5. 验收与答辩用三组可复现测试向导师证明你真懂 AES答辩不是背概念而是展示你能让代码在真实硬件上跑通、可验证、可解释。我带过的某高校课程设计中80% 的同学倒在“说不清为什么选 ECB”或“调不通 CBC”。以下三组测试是我要求学生必须当面演示的验收项每组都附带可直接粘贴进 Keil/IAR 的验证代码。5.1 测试组一ECB 模式黄金标准验证NIST FIPS-197 Annex ANIST 文档明确给出 AES-128 ECB 的测试向量Key0x000102030405060708090a0b0c0d0e0fPlaintext0x00112233445566778899aabbccddeeffCiphertext0x69c4e0d86a7b0430d8cdb78070b4c55a。这是最硬核的验收——结果不对说明轮函数或密钥扩展有根本错误。// test_ecb.c #include aes_core.h #include mode_ecb.h void test_ecb_nist() { uint8_t key[16] {0x00,0x01,0x02,0x03,0x04,0x05,0x06,0x07, 0x08,0x09,0x0a,0x0b,0x0c,0x0d,0x0e,0x0f}; uint8_t plain[16] {0x00,0x11,0x22,0x33,0x44,0x55,0x66,0x77, 0x88,0x99,0xaa,0xbb,0xcc,0xdd,0xee,0xff}; uint8_t cipher[16]; uint8_t expect[16] {0x69,0xc4,0xe0,0xd8,0x6a,0x7b,0x04,0x30, 0xd8,0xcd,0xb7,0x80,0x70,0xb4,0xc5,0x5a}; aes_key_schedule_t ks; aes_key_setup(key, ks); ecb_encrypt(plain, cipher, 16, ks); // 用 UART 打印结果或用逻辑分析仪抓 GPIO for (int i 0; i 16; i) { if (cipher[i] ! expect[i]) { // 点亮红灯表示失败 LED_RED_ON(); return; } } LED_GREEN_ON(); // 通过 }注意此测试必须在未开启任何编译器优化-O0下运行否则aes_key_setup()可能被内联或寄存器优化干扰。5.2 测试组二CBC 模式链式完整性验证自构造 3 块数据ECB 只验证单块CBC 必须验证“链式”是否生效。构造明文AABBCCDD...16 字节、11223344...16 字节、ABCDEF00...16 字节用固定 IV0x00000000000000000000000000000000加密。改变第二块明文观察第三块密文是否变化——若不变说明 CBC 链断裂。// test_cbc_chain.c uint8_t iv[16] {0}; // 全0 IV uint8_t plain1[16] {/* 16字节 */}; uint8_t plain2[16] {/* 16字节 */}; uint8_t plain3[16] {/* 16字节 */}; uint8_t cipher[48]; // 3块密文 // 加密 cbc_encrypt(plain1, cipher, 16, iv, ks); cbc_encrypt(plain2, cipher16, 16, iv, ks); // 错这里应传 cipher cbc_encrypt(plain3, cipher32, 16, iv, ks); // 错 // 正确写法必须链式调用 uint8_t full_plain[48] {0}; memcpy(full_plain, plain1, 16); memcpy(full_plain16, plain2, 16); memcpy(full_plain32, plain3, 16); cbc_encrypt(full_plain, cipher, 48, iv, ks); // 一次传48字节验收动作修改plain2[0]为0xFF重新运行对比cipher[32..47]第三块是否变化若不变说明cbc_encrypt()内部未正确将前一块密文作为下一轮prev需检查mode_cbc.c中prev更新逻辑。5.3 测试组三PKCS#7 边界 case 验证0 字节、15 字节、16 字节明文这是答辩时最容易被导师追问的点。准备三组输入len0pkcs7_pad()应返回16填充 16 个0x10len15返回16填充 1 个0x01len16返回32填充 16 个0x10。// test_padding.c void test_pkcs7_edge() { uint8_t buf[64]; uint16_t len; // Case 1: len0 len pkcs7_pad(buf, 0, 64); if (len ! 16) goto fail; for (int i 0; i 16; i) if (buf[i] ! 0x10) goto fail; // Case 2: len15 memset(buf, 0, 64); for (int i 0; i 15; i) buf[i] i; len pkcs7_pad(buf, 15, 64); if (len ! 16) goto fail; if (buf[15] ! 0x01) goto fail; // Case 3: len16 memset(buf, 0, 64); for (int i 0; i 16; i) buf[i] i; len pkcs7_pad(buf, 16, 64); if (len ! 32) goto fail; for (int i 0; i 16; i) if (buf[16i] ! 0x10) goto fail; LED_GREEN_ON(); return; fail: LED_RED_ON(); }从那以后我每次写嵌入式密码模块都强制走一遍这三组测试——不是为了应付答辩而是因为 AES 看似简单实则是无数细节咬人的黑匣子。ECB 的“不安全”是教科书结论但它的轻量是嵌入式刚需CBC 的链式是标准但 IV 的字节序、填充的边界、密钥的存储位置任何一个没抠准就会在量产前夜变成后悔药。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑