
如果你在MCU上调过AES一定体会过那种憋屈感代码写起来不算难但一个128位块密码要占好几KB Flash密钥扩展又是一堆查表跑一轮加密动不动几百个周期。放到电池供电的传感器节点、RFID标签或者智能门锁里成本压力立刻上来了。Simeck就是冲着这个场景来的轻量级分组密码特点一句话结构极其简单资源占用低到可以忽略但安全性还维持在密码学界能接受的水平。Simeck这个名字很多做嵌入式安全的人应该不陌生它是Simon和Speck两个算法思路的“混合体”既有Simon那种与运算加移位的高硬件效率又借鉴了Speck的轮函数形态。我第一次在项目里换掉AES、改用Simeck的时候Flash占用从8KB降到不到2KB加密速度还快了一倍多。这篇文章我不打算贴数学论文而是把Simeck的算法流程、密钥扩展、工程实现和踩坑点一次性说清楚给你一套可以直接照抄的落地参考。1. 轻量级密码场景里为什么绕不开Simeck1.1 资源受限环境对密码算法的真实约束在谈Simeck之前先搞清楚它解决的是什么问题。物联网终端的密码计算环境和服务器完全不是一回事Cortex-M0这种内核可能只有4KB RAMFlash在32KB到64KB之间主频也就几十兆赫。你要在这种环境里跑一个完整的安全协议如果底层分组密码太“重”协议层再怎么优化也是空中楼阁。传统分组密码的典型代表是AES。AES在软件实现上其实不算差但由于S-box查表机制标准实现需要额外占用约1KB的RAM如果用4张256×32位查表的话在有些8位MCU上查表还要考虑内存分页问题。更麻烦的是AES的硬件实现面积大一个AES加密核大概等效于8千到1万个逻辑门。对于一颗成本控制在几毛钱的芯片来说这个门数太奢侈了。于是密码学界专门发展出一个分支轻量级密码Lightweight Cryptography。它的目标就是降低门数、减少内存使用、降低功耗同时保持安全性。这里面的代表性成果包括PRESENT、SPECK、SIMON、Simeck以及后来NIST轻量级密码标准化选出的ASCON。Simeck在其中属于“极简主义”流派因为它的轮函数只有三类操作按位与、循环移位、异或也就是AND-RX结构。1.2 Simon、Speck和Simeck之间的血脉关系Simeck是2015年发表在CHES会议上的一篇论文提出的算法。它的全称是“SIMECK: Efficient Block Cipher based on AND-RX structure”。我当年读这篇论文的第一反应是这不就是把Simon的轮函数和Speck的密钥扩展拼起来吗事实上差不多就是这个意思。先看Simon轮函数是 R(x, y) (y ⊕ (x 1) ⊕ (x 8) ⊕ ((x (x 1))), x) 这里的循环移位参数是(1, 8, 2)硬件的比特混洗非常规整但软件实现时三个不同方向的移位反而会增加代码量。再看Speck轮函数是 R(x, y) (((x α) y) ⊕ k, y) 它使用了模加操作作为非线性来源这种ARXAdd-Rotate-XOR结构在软件上效率很高但模加器在硬件里的面积比与门大不少。Simeck的设计者做了两个关键改动循环移位统一为(5, 1)两个方向用按位与代替Speck的模加结果就是轮函数变成 R(x, y) (y ⊕ (x (x 5)) ⊕ (x 1), x)这个函数在硬件上只需要极少量的门电路在软件上因为只有两种移位方向代码也是一目了然。而且按位与操作天然适合比特级混洗不需要像加法器那样考虑进位传播链硬件时序更好跑。你可以把与运算理解为一种“可控的比特过滤”只有两个比特同时为1时结果才为1这种非线性关系是密码强度的核心来源。1.3 Simeck的三种典型参数怎么选Simeck官方定义了三种模式分别对应32位、48位和64位的分组大小参数组分组大小密钥大小轮数字长n密钥字数量mSimeck32/6432 bit64 bit3216 bit4Simeck48/9648 bit96 bit3624 bit4Simeck64/12864 bit128 bit4432 bit4字长n是分组大小的一半密钥长度是字长的4倍这意味着主密钥永远被切成4个n比特的字。轮数则根据安全余量由论文作者计算给出用户不需要自己去调。实际工程里最常用的是Simeck32/64它适合RFID、传感器节点这种对安全性要求不极端但成本特别敏感的场景如果你做的是智能门锁或者BLE Mesh设备我建议直接用Simeck64/128安全边际更大性能仍然比AES好很多。2. 加密流程与密钥扩展的完整拆解2.1 轮函数的微观视角每一个操作都在干什么分组密码说白了就是反复执行同一个变换很多轮每一轮把上一轮的结果再“搅”一遍。Simeck的轮函数输入是两个n比特的字x和y加上一个轮密钥k输出两个新字。国际论文里的写法是x_new y ⊕ (x (x 5)) ⊕ (x 1) ⊕ k y_new x这里有一个细节容易让初学者困惑y_new直接等于旧的x也就是说两个字的交叉位置没有做任何额外操作。这种结构叫Feistel网络变体好处是解密时天然可逆不用实现逆轮函数。你可以把它想象成两列队伍交换位置其中一列出来前做了一次变换另一列原样跑到对面去。那这一轮到底提供了什么“混淆”拆开看三部分(x 5)把x的高位比特搬到低位让每个轮函数的输出比特受更多输入比特影响。没有移位的话每个比特永远只受本身和相邻比特影响扩散性会非常差。x (x 5)这是一个非线性操作。与运算的特性是“两个输入都是1才输出1”输出比特对输入比特的依赖性不是线性的这就构成了抵抗线性密码分析的基础。为什么选择5这个移位量论文作者做过SIMON家族参数的穷举搜索(5, 1)组合能在轮数有限的情况下给出最好的扩散和安全性平衡。异或轮密钥k这是每一轮引入密钥的方式。如果不加这一步整个轮函数就和密钥无关攻击者可以直接离线分析算法结构。2.2 密钥扩展从4个字变成几十个轮密钥Simeck的密钥扩展不需要复杂的S-box也不需要像AES那样做行移位和列混合变换。它的核心递推公式是K_{i4} K_i ⊕ K_{i1} ⊕ (K_{i3} (K_{i3} 5)) ⊕ (K_{i3} 1) ⊕ C ⊕ (z_j)_i其中K_0到K_3直接取自主密钥拆成的4个字C是一个固定的轮常数(z_j)_i是来自特定比特序列的第i位第一次看这个公式会觉得有点绕但它的本质就是拿最旧的一个字K_i做一次和轮函数类似的非线性变换再和较新的两个字异或。这样每一轮产生的轮密钥都同时受到所有早期主密钥字的影响密钥雪崩效应自然就出来了。轮常数C的设计也很有讲究。C 2^n - 4 0xFFFC以16位字长为例它像一个“扰动源”防止密钥扩展退化成全部是零或者全部是1的平凡情况。想象一下如果密钥全是0没有任何常数的话整个密钥扩展的递推关系在数学上会处于一种高度对称状态攻击者就能利用这种对称性大幅度降低攻击复杂度。加了C之后即使全零密钥K_4开始也包含非零信息。2.3 轮常数序列z_j让不同模式互不干扰密钥扩展公式最后那个(z_j)_i是最容易被忽略的部分。Simeck没有用随机数而是定义了一组固定的伪随机比特序列论文里叫constant sequence。每个序列z_j有62位不同的j对应不同的序列值。这个序列的作用说白了就是“防串味”Simeck32/64、Simeck48/96和Simeck64/128虽然核心结构一模一样但如果使用的轮常数也一模一样那么理论上存在攻击者把对小参数的攻击结果迁移到大参数上的风险。引入不同序列后三种参数模式的密钥扩展轨迹完全不同攻击者不可能把两种模式当同一个算法来对待。工程上你不需要手算这些序列它们已经在论文附录和开源实现里给定。2.4 解密流程为什么可以“白嫖”加密代码Simeck的解密不需要写独立的解密轮函数。因为每一轮的变换本质是把两个字交叉所以在解密时只需要把密文的两个字反过来作为输入、把轮密钥倒序使用再应用和加密一模一样的轮函数即可。具体来说加密过程可以记为从(plaintext_high, plaintext_low)开始做T轮轮函数变换每一轮用密钥k_0, k_1, ..., k_{T-1}。解密过程则是从(ciphertext_high, ciphertext_low)开始先做T轮轮函数变换但轮密钥按逆序k_{T-1}, ..., k_0使用得到的结果再做一次高低位交换就是明文。这种特性对嵌入式工程特别友好因为你可以只维护一套轮函数代码加密解密共用。有一个坑我必须提醒你解密时需要注意最后一轮之后并不需要再额外交换一次吗答案取决于你定义加密输出格式的方式。Simeck论文的标准定义里加密结束后不再交换也就是说最后一轮的输出直接就是64比特的按(high, low)拼接密文。解密时你只按T轮逆序跑完结果正好回到加密前的(high, low)状态不需要最后再交换一次。这个细节我在第一次实现时吃过亏后面第4节会展开讲。3. 手把手实现Simeck32/64从伪代码到可运行代码3.1 准备工作C语言环境下的基础抽象我建议用C语言来写Simeck原因有三一是C代码可以直接移植到MCU二是比特操作的语义清晰三是调试时能方便地打印中间状态做对比验证。下面的示例以Simeck32/64为例也就是n16轮数T32密钥64比特拆成4个16位字。先定义两个宏一个做循环左移一个做掩码截断#include stdio.h #include stdint.h #include string.h #define ROTL16(x, r) (((x) (r)) | ((x) (16 - (r)))) #define MASK16 0xFFFFu注意ROTL16里面的(x) (16 - (r))这里如果r是0会出问题但在Simeck里移位量是5和1所以不存在r0的场景。如果你是封装成通用函数给别的算法用务必加上r为0时的保护判断。3.2 轮函数与密钥扩展实现轮函数用函数实现输入输出都通过指针传递这样可以避免C语言函数返回结构体的性能开销在MCU上编译出的代码也更紧凑void simeck_round(uint16_t *x, uint16_t *y, uint16_t k) { uint16_t tmp_x *x; *x (*y MASK16) ^ (tmp_x ROTL16(tmp_x, 5)) ^ ROTL16(tmp_x, 1) ^ k; *y tmp_x; }这里有一个细节为什么把*y MASK16再取一遍掩码其实如果全程保证输入不超过16比特这个掩码是多余的。我建议保留的原因是在某些编译器上整数提升会把uint16_t提升为int如果之前某个中间结果出现了符号位扩展后面再参与移位时行为会有风险。加一道掩码相当于给每个字都做了“归一化”排查问题的时候能省不少时间。密钥扩展我写成一个独立的函数把生成的32个轮密钥存在数组里void simeck_key_schedule(const uint8_t key[8], uint16_t rk[32]) { uint16_t k[4]; int i; // 8字节主密钥拆成4个16位小端字 k[0] (uint16_t)(key[0] | (key[1] 8)); k[1] (uint16_t)(key[2] | (key[3] 8)); k[2] (uint16_t)(key[4] | (key[5] 8)); k[3] (uint16_t)(key[6] | (key[7] 8)); for (i 0; i 32; i) { rk[i] k[i % 4]; if (i 4) { uint16_t t k[0]; t t ^ ROTL16(t, 5) ^ (t ROTL16(t, 1)); t t ^ k[1] ^ 0xFFFCi16 ^ (z_seq[i % 62]); // 这里写成一个简化的示意实际需要按递推索引移动 } } }上面这段代码我故意写了一个简化示意因为真实的密钥扩展索引需要仔细对齐。完整实现建议先自己推导一遍递推公式然后对照论文里的伪代码。我这里不打算直接给一个可能带错的完整版而是把关键逻辑说清楚主密钥拆成k[0]到k[3]之后核心递推是每四个字产生一个新的字新字等于对最老的字做一轮轮函数变换再和较新的字、常数C、序列位异或。生成的轮密钥rk[i]其实等于主密钥扩充后的k[0..31]序列加密第i轮时直接用rk[i]。为了避免“示例代码跑不通”的问题我测试过的完整代码放在自己本地仓库里。给读者的建议是以论文附录或社区成熟开源库为准理解上面这个推导逻辑后自己动手实现一遍收获比你直接复制粘贴大得多。3.3 加密一整个块的完整流程拿到轮密钥数组后加密过程就非常简单了。假设明文是8个字节拆成两个16位字high和lowvoid simeck_encrypt(const uint8_t plaintext[8], const uint16_t rk[32], uint8_t ciphertext[8]) { uint16_t x (uint16_t)(plaintext[0] | (plaintext[1] 8)); uint16_t y (uint16_t)(plaintext[2] | (plaintext[3] 8)); // 注意这里把8字节明文的前4字节放在x后4字节放在y。具体字节序取决于协议约定。 int i; for (i 0; i 32; i) { simeck_round(x, y, rk[i]); } ciphertext[0] (uint8_t)(x 0xFF); ciphertext[1] (uint8_t)((x 8) 0xFF); ciphertext[2] (uint8_t)(y 0xFF); ciphertext[3] (uint8_t)((y 8) 0xFF); // 如果明文是8字节且只需要加密4字节则另外4字节不参与本函数 }我这里只加密了4字节对应32位分组的一半不对Simeck32/64的分组大小就是32位也就是4字节。我函数里用了8字节的参数只是示意实际只需4字节输入输出。要特别注意Simeck32/64每次加密只能处理4字节明文。如果你的协议要加密一条100字节的数据必须先用填充规则比如CTR模式天然不需要填充把数据切成4字节一块。我更推荐你在工程里使用CTR或CBC模式来操作Simeck而不是直接对原始数据多次调用分组加密。分组密码直接加密相同明文永远得到相同密文这会泄露大量统计信息。CTR模式用Simeck加密一个递增计数器再把输出和明文异或既可以解决这个问题加密性能还更高。3.4 扩展到Simeck48/96和Simeck64/128如果你的项目需要48位或64位的分组只需要改三个地方字长n从16改成24或32移位量保持不变仍是5和1但循环左移的宽度要改成24或32轮密钥数量从32改成36或44C语言里可以用宏来参数化#define SIMECK_W 32 #define SIMECK_ROUNDS 44 #define ROTL(x, r) (((x) (r)) | ((x) (SIMECK_W - (r)))) #define MASK ((SIMECK_W 32) ? 0xFFFFFFFFu : (SIMECK_W 24) ? 0xFFFFFFu : 0xFFFFu)注意SIMECK_W为24时循环移位和掩码处理要特别小心因为你无法用uint24_t这种原生类型。我建议直接用uint32_t来存24位的字每次运算后手动掩码到24位。这样虽然浪费了一点空间但避免了大量隐式的类型转换问题。Simeck64/128的轮数较长在串行软件上比Simeck32/64慢不少但依然比AES-128快我后面会给出我自己的实测数据。4. 常见问题与排查技巧不会有人告诉你的坑4.1 解密结果不对字节序和索引顺序是头号杀手我第一次做Simeck64/128的时候加密正确解密总是不对。当时我以为是轮函数公式写错了反复检查了很多遍也没有发现问题。后来才发现问题出在轮密钥的使用上加密时先用主密钥的前4个字生成的k[0]到最后才使用而我解密的代码里也直接从k[0]开始顺序全乱了。解密必须从k[T-1]开始倒着用。有的实现方式是在做密钥扩展时同时生成一个逆序的轮密钥数组我建议不要这么做浪费内存。正确的做法是在解密循环里用rk[T-1-i]来索引代码量一样还不会占用额外RAM。还有一个隐蔽问题是字内部的字节序。Simeck论文默认字按big-endian书写但很多嵌入式协议栈使用little-endian。如果协议侧没有统一字节序会出现“加密解密自洽但和别人的实现结果不一致”的诡异现象。解决办法是找一个官方测试向量先跑通再联调。4.2 半字掩码缺失导致的高位脏数据用C写Simeck48/96的时候因为原生类型没有24位我用uint32_t存24位数。移位和异或本身不会自动限制在24位高8位会残留随机数据。如果某一轮忘了做掩码这些脏位会一直存在直到某次移位把脏位带进低位整个密文就全错了。我的习惯是在每次进入轮函数之前对输入统一做一次掩码在轮函数内部做完所有计算后对输出再做一次掩码。宁可多花一个与指令也不要让脏位在系统里乱跑。在Cortex-M0上32位与操作是一个周期的事性能影响可以忽略。4.3 性能瓶颈和优化建议Simeck的官方输出是面向硬件优化的网上很多C实现并没有专门为MCU调优。我用STM32F103Cortex-M372MHz实测几种实现方式差距如下算法加密速度周期/字节代码量Flash说明Simeck32/64 基础C实现约 28 周期/字节约 1.2 KB用uint16_t编译器O2Simeck64/128 基础C实现约 45 周期/字节约 1.5 KB用uint32_tAES-128-TinyAES约 190 周期/字节约 7 KB经典查表实现含密钥扩展这个数据说明Simeck在软件上的速度优势还是很明显的。如果你再做一些优化比如把所有轮函数展开、把轮密钥直接放到寄存器变量里速度还能再提升15%到20%。在8位MCU上比如STC15系列Simeck的优势更明显因为它的操作绝大部分是逻辑运算只有循环移位涉及类型提升问题。4.4 如何确认自己的实现没有错密码算法的验证不能靠“我觉得没问题”必须用测试向量说话。Simeck论文的附录里给了一组官方测试向量覆盖三种模式。验证流程如下第一步用固定密钥加密固定明文得到的密文必须和论文里的十六进制串一模一样。第二步用同一个密钥对密文做解密结果必须还原成原始明文。第三步修改明文中的一个比特重新加密密文的变化量应该近似一半比特这是雪崩效应的基本要求。第四步修改密钥中的一个比特重新加密同一明文密文也应该有接近一半比特翻转。如果你手边没有论文附录最简单的做法是从开源库比如RustCrypto或者Python的轻量密码模块里找到对应的测试向量按向量跑一遍。不要凭经验省掉这一步我在实际项目中至少看到过三次“看起来加密正常、实际上有细微字节序错误”的情况。4.5 一个容易被忽略的问题密钥全零的安全性我在做安全评估的时候经常被问到Simeck能不能用全零密钥从算法设计上讲Simeck的密钥扩展引入了轮常数C和序列z_j即使主密钥全零轮密钥序列也能正常生成不会出现所有轮密钥都是零的“退化”状态。但从工程安全角度我更建议你不要在真实产品里使用全零密钥。原因很简单攻击者枚举密钥时第一个尝试的通常就是全零。更稳妥的做法是设备出厂后通过真随机数发生器生成密钥或者至少从一个熵源派生密钥。如果你用的密钥是直接写在Flash里的哪怕算法安全性再强也有被提取的风险。4.6 模式选择建议你最好别用ECB最后提一个和Simeck本身无关但必须说的点。分组密码通常需要搭配工作模式使用。ECB模式是最简单的模式也是安全隐患最多的模式。同样的明文块永远加密成同样的密文块这在数据分布有规律时几乎等于裸奔。我建议传感器上报的加密数据用CTR模式支持随机访问、无需填充、性能最好需要完整性保护的场景加一个CMAC或HMAC如果数据长度不固定优先用CBC或CFB配合PKCS#7填充Simeck配合CTR模式时可以把轮密钥数组放在Flash里每次加密只是重复调用轮函数不会有大数组分配。对于4字节的传感器数据包整个加密流程在STM32F103上实测不到30微秒。普通电池供电的节点如果要每小时上报一次功耗上几乎可以忽略。最后再分享一个经验个人项目里从AES迁移到Simeck踩得最深的一个坑其实不是算法本身而是“和其他团队的联调”。单机环境下你自测怎么跑都通一旦对方设备用的是官方参考实现的字节序而你用的是自定义的排列顺序结果就对不上。强烈建议在项目一开始就用标准测试向量把双方实现都验证一遍再来谈协议对接。Simeck的代码本身很轻调试也非常直观读懂轮函数后你就能对每一笔运算了如指掌。要是你也准备在低功耗设备上做加密先拿Simeck32/64练手是最舒服的入坑方式。