ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言实现古典密码:凯撒加密与棋盘加密的代码实践与避坑指南

C语言实现古典密码:凯撒加密与棋盘加密的代码实践与避坑指南

简介:这是一份面向密码学初学者与C语言学习者的C代码包,实现了棋盘加密(Playfair Cipher)与凯撒加密(Caesar Cipher)两种古典密码的加密及解密过程。棋盘加密基于5×5棋盘对明文双字母组合进行替换,凯撒加密通过固定移位实现加解密,代码直观展示了数组、循环与条件语句在算法落地中的用法。压缩包共2个文件,包含1个cpp源代码文件与1个txt说明文件,整体体积仅2KB,轻量易读。已有2156人学习,适合用于理解古典密码原理、对照教材进行实验,也可作为C语言字符串处理与逻辑设计的练习素材。通过运行和阅读代码,能清晰看到字符映射与移位处理的具体实现,为后续学习更复杂的现代密码学打下基础。

1. 棋盘加密与凯撒加密:为什么还在用C写古典密码

棋盘加密解密和凯撒加密解密放在一起,是最容易在C语言课设、嵌入式小实验和古典密码入门题里同时撞见的一对组合。凯撒是单表替换,棋盘(Polybius Square)是坐标映射,两者都不依赖复杂的密钥体系,只靠一张盘面和一个位移数就能完成加解密。这类资源最大的价值在于代码量小、逻辑直白,适合用来练习数组、字符串处理、函数拆分和文件读写。如果你手头正在做课设或者想快速补一个能跑的C密码实现,先把两个算法的边界理清,后面改参数、扩功能都不会翻车。适合用这份资源的人,是那种需要交可运行C代码、又不想把古典密码写成黑匣子的人。

2. 凯撒加密解密:模26处理与两行核心代码

2.1 凯撒的本质是循环位移:为什么先处理 shift

凯撒加密就是给每个字母的偏移量加上一个固定值,落到C语言里,核心就是一次取模运算。但C的取模和数学上的模不是一回事,这是写这个算法最先要确认的点。假设字母表只有A到Z 26个字符,'A'加3得到'D','Z'加1要绕回'A',这本质是环上的位移。可C语言里%是截断除法,负数取模结果还是负数,直接用会导致解密时坐标错位。我一般会把移位参数先规整到0到25之间,再参与运算,这样加密和解密共用一套逻辑,不用区分正负。

用户的输入不可能永远规范,shift可能传负数,可能传30甚至更大。规整的方式是把shift先对26取余,如果结果是负数就再加26。这个逻辑单独抽成一个函数,后面所有加解密入口都复用,能少踩很多坑。另一个容易忽略的点是C的char可能是带符号的,直接拿一个负值去做算术和字符判断会出问题,这个放到后面避坑章节细说。

2.2 加密函数实现:normalize 与字符映射

下面的代码是整套凯撒加密的基础,加密和解密都依赖规整后的移位值。注意加密只处理字母,数字、空格和标点原样返回,这样坐标流这类密文也能被凯撒二次处理,不会破坏格式。

#include <ctype.h> #include <stdio.h> #include <string.h> /* * 把任意整数规整到 0-25 的合法移位区间。 * C 的 % 对负数取模仍为负,必须手动补回 26。 */ int normalize_shift(int shift) { int s = shift % 26; if (s < 0) { s += 26; } return s; } /* * 加密单个字符:字母按 base 偏移,非字母原样返回。 * 这里将 c 显式转成 unsigned char,避免 isalpha 收到负值。 */ char caesar_shift_char(char c, int shift) { if (!isalpha((unsigned char)c)) { return c; } char base = isupper((unsigned char)c) ? 'A' : 'a'; int offset = (int)(c - base + normalize_shift(shift)) % 26; return (char)(base + offset); } /* * 加密整串:调用方保证 cipher 缓冲区有 len+1 空间。 */ size_t caesar_encrypt_string(const char *plain, char *cipher, size_t cap, int shift) { size_t len = strlen(plain); if (len >= cap) { len = cap - 1; } for (size_t i = 0; i < len; i++) { cipher[i] = caesar_shift_char(plain[i], shift); } cipher[len] = '\0'; return len; }

normalize_shift是整套算法的地基,加密和解密都先过它。base用来区分大小写,大小写各自在独立的26个位置上循环,不会出现大写变成小写的情况。isalpha((unsigned char)c)这处转换是关键,后面避坑章节再展开。整串加密的cap参数是必须的,C字符串没有长度信息,缓冲区溢出是这类代码最容易犯的错误,宁可多传一个容量,不要省这个参数。

2.3 解密函数与暴力扫描:负移位怎么规整

解密就是加密的逆运算。有人会写(c - base - shift) % 26,一旦shift规整过、且当前字母靠前(比如B减3),结果就会变成负数,解密直接乱掉。这里的做法是先把移位规整成正数,再用加26修正,保证中间结果始终在0到25之间。

/* * 解密单个字符:base 加上规整后的偏移,并做一次 +26 保护。 */ char caesar_unshift_char(char c, int shift) { if (!isalpha((unsigned char)c)) { return c; } char base = isupper((unsigned char)c) ? 'A' : 'a'; int s = normalize_shift(shift); int offset = (int)(c - base - s); if (offset < 0) { offset += 26; } return (char)(base + offset); } /* * 解密整串,逻辑与加密对称。 */ size_t caesar_decrypt_string(const char *cipher, char *plain, size_t cap, int shift) { size_t len = strlen(cipher); if (len >= cap) { len = cap - 1; } for (size_t i = 0; i < len; i++) { plain[i] = caesar_unshift_char(cipher[i], shift); } plain[len] = '\0'; return len; } /* * 暴力扫描:凯撒只有 25 个有效移位,全部打出来人工识别。 */ void caesar_bruteforce(const char *cipher) { for (int s = 1; s < 26; s++) { printf("shift %2d: ", s); for (size_t i = 0; cipher[i] != '\0'; i++) { putchar(caesar_unshift_char(cipher[i], s)); } putchar('\n'); } }

注意解密时的offset修正顺序:先减完再加26,只在结果为负时补偿,这比直接写(c - base + 26 - s) % 26更直观,也不容易算错。暴力扫描在密文不长时非常管用,因为凯撒的密钥空间只有25,人工扫一眼就能认出来。后面进阶章节会把它组合进完整链路里做一个自动验证工具。

3. 棋盘加密解密:5×5方格与坐标流编码

3.1 26个字母怎么塞进25个格子

棋盘加密的基础是波利比奥斯棋盘,一个5×5的矩阵,每个格子放一个字母。问题马上出现:英文有26个字母,矩阵只有25格。通行的做法是把I和J合并到同一格,解密时遇到这个坐标只能还原成I,原文里的J就会丢失。这是棋盘加密有损的直接原因,不是代码bug。

我见过有人为了避开I/J合并,把盘面扩成6×6,多出来的一行放数字0-9或直接放J。这样确实能保真,但坐标范围变成1-6,密文长度和字符集都变了。如果只是做课设,用字母序铺5×5表是最稳的,因为很多参考书和在线校验工具都默认这种盘面。网格铺法一般是按字母序一行一行填:ABCDE、FGHIK、LMNOP、QRSTU、VWXYZ,K保留,J并入I。

3.2 初始化棋盘表与坐标编码函数

棋盘表的初始化和坐标查表是分开的两个函数。表可以写成全局常量,但我会推荐在函数里现铺,因为后面文件版加解密还要复用同一套初始化逻辑,封装成函数比全局变量更干净。

#define POLYBIUS_SIZE 5 /* * 按字母序铺满 5x5 矩阵,J 被 I 顶替。 * 字符串里没有 J,只有 I,这是 25 格棋盘的硬约束。 */ void polybius_init_table(char table[POLYBIUS_SIZE][POLYBIUS_SIZE]) { const char *alphabet = "ABCDEFGHIKLMNOPQRSTUVWXYZ"; int k = 0; for (int r = 0; r < POLYBIUS_SIZE; r++) { for (int c = 0; c < POLYBIUS_SIZE; c++) { table[r][c] = alphabet[k++]; } } } /* * 查表编码:把单个字母映射成行、列(从 1 开始编号)。 * 遇到 J 先替换成 I;查不到返回 -1。 */ int polybius_encode_char(const char table[POLYBIUS_SIZE][POLYBIUS_SIZE], char ch, int *row, int *col) { char upper = toupper((unsigned char)ch); if (upper == 'J') { upper = 'I'; } for (int r = 0; r < POLYBIUS_SIZE; r++) { for (int c = 0; c < POLYBIUS_SIZE; c++) { if (table[r][c] == upper) { *row = r + 1; *col = c + 1; return 0; } } } return -1; } /* * 查表解码:坐标(1-5, 1-5)还原成字母。 */ int polybius_decode_char(const char table[POLYBIUS_SIZE][POLYBIUS_SIZE], int row, int col, char *out) { if (row < 1 || row > POLYBIUS_SIZE || col < 1 || col > POLYBIUS_SIZE) { return -1; } *out = table[row - 1][col - 1]; return 0; }

polybius_encode_char把坐标的返回区间定在1到5而不是0到4,是因为密文最终要落成'1'到'5'的ASCII字符,加1后直接用'0' + row就能转,省去一步换算。解码入口做了越界判断,非法坐标直接返回-1,不会写坏out。这套函数设计成单字符粒度,后面整串加解密和文件版都能复用,不需要重复查表逻辑。

3.3 整串加密与解码:空格分隔的坐标流

整串加密的核心问题有两个:一是坐标流的格式,二是缓冲区大小。棋盘加密后每个字母变成两个数字字符,密文长度大约是明文的2倍,加上分隔空格接近2.5倍,缓冲区开小了会直接截断。我一般建议按明文长度的3倍开密文缓冲区,留足余量。

下面的实现用空格作为坐标对的分隔符,比如"HELLO"加密成"23 15 13 13 34"。空格让坐标对边界清晰,也方便人眼核对,缺点是密文长度又增加了三分之一。如果追求紧凑,可以去掉空格输出"2315131334",但解密时如果坐标长度不统一会有歧义,这个在避坑章节专门说。

/* * 整串加密:每个可映射字符输出 "行 列" 两个数字,坐标对之间用空格隔开。 */ size_t polybius_encrypt_string(const char *plain, char *cipher, size_t cipher_cap) { char table[POLYBIUS_SIZE][POLYBIUS_SIZE]; polybius_init_table(table); size_t pos = 0; for (int i = 0; plain[i] != '\0' && pos + 3 < cipher_cap; i++) { int row = 0; int col = 0; if (polybius_encode_char(table, plain[i], &row, &col) != 0) { continue; /* 非字母或无法映射的字符直接跳过 */ } if (pos > 0) { cipher[pos++] = ' '; /* 坐标对分隔符 */ } cipher[pos++] = (char)('0' + row); cipher[pos++] = (char)('0' + col); } cipher[pos] = '\0'; return pos; } /* * 整串解码:解析空格分隔的坐标对,还原成字母串。 * 坐标必须满足 '1'-'5' 范围,其他字符一律忽略。 */ size_t polybius_decrypt_string(const char *cipher, char *plain, size_t plain_cap) { char table[POLYBIUS_SIZE][POLYBIUS_SIZE]; polybius_init_table(table); size_t pos = 0; int row = 0; int got_row = 0; for (const char *p = cipher; *p != '\0' && pos + 1 < plain_cap; p++) { if (*p == ' ') { got_row = 0; /* 空格表示新坐标对开始 */ continue; } if (*p < '1' || *p > '5') { continue; /* 非法字符跳过,不中断解析 */ } if (!got_row) { row = *p - '0'; got_row = 1; } else { char ch = '\0'; if (polybius_decode_char(table, row, *p - '0', &ch) == 0) { plain[pos++] = ch; } got_row = 0; } } plain[pos] = '\0'; return pos; }

加密循环里的pos + 3 < cipher_cap是缓冲区保护,每次写入前先确认放得下两个数字加一个分隔符,防止越界写。解密端的got_row标记state machine思路比每次定位下标更不容易出错,状态只有两个:等行、等列。解析时不依赖空格也能切分,因为字符范围被限制在'1'-'5',出现两个连续数字就自动组成一个坐标对,这个设计对后面处理无分隔密文很有用。

4. 文件级加解密:把内存字符串改成流式处理

4.1 fopen 模式、EOF 与文件格式边界

字符串版跑通之后,文件版就只是换了数据来源和出口。但有一个细节必须提前说清楚:文件打开模式必须用"rb"和"wb"。如果开成文本模式,Windows下的CRLF转换会把写入的0x0A自动扩成0x0D 0x0A,密文文件里凭空多出一堆回车符;反过来读文件时,遇到0x1A会被当成EOF提前截断。古典密码的密文是ASCII字符,理论上文本模式可能能跑,但二进制模式能消除所有平台差异,我没必要去赌那个可能性。

文件版处理的是字节流,判断文件结束用fgetc的返回值跟EOF比较,而不是用feof去猜。fgetc返回int而不是char,是因为需要用一个特殊值来表示读到结尾。循环里直接拿这个int去做字符判断,注意先把值转成unsigned char再交给isalpha这类宏,否则扩展字符集下行为未定义。

4.2 凯撒文件版:逐字节读入逐字节吐出

凯撒文件版几乎没有额外的格式负担,因为加密不改变字符数量,逐字节读入、逐字节输出就行。唯一要处理的是非字母字符,文件里的换行、空格、数字都应该原样保留,这样解密后文件内容能完整还原。

/* * 凯撒文件加密:rb/wb 模式逐字节处理。 * 返回 0 成功,-1 表示打不开输入或输出文件。 */ int caesar_encrypt_file(const char *in_path, const char *out_path, int shift) { FILE *fin = fopen(in_path, "rb"); if (fin == NULL) { return -1; } FILE *fout = fopen(out_path, "wb"); if (fout == NULL) { fclose(fin); return -1; } int c; while ((c = fgetc(fin)) != EOF) { if (isalpha((unsigned char)c)) { char base = isupper((unsigned char)c) ? 'A' : 'a'; int s = normalize_shift(shift); c = (char)(base + (c - base + s) % 26); } fputc(c, fout); } fclose(fin); fclose(fout); return 0; }

这段代码的核心是base的区分和(c - base + s) % 26的映射,和字符版逻辑一致。注意c是int,判断完字母映射后再赋给fputc,中间不会丢符号位。文件版的好处是内存不放大,处理多行文本也没有压力。解密文件名如果和加密文件名一样,必须先确认两个FILE*都打开成功,再开始读循环,否则一次误操作就把源文件清空了。

4.3 棋盘文件版:密文坐标流的落盘格式

棋盘文件版有一个字符串版没有的问题:源文件里会有大量换行和空格,如果全部当作普通字符处理,空格无法进棋盘,换行无法映射成坐标。处理策略是跳过所有空白字符,只对字母编码,标点和数字因为只能在26字母表里查不到,也一并跳过。这意味着棋盘加密的文件版是明显有损的,原始文件里的格式信息在密文里不存在。

/* * 棋盘文件加密:坐标对之间用空格分隔,落盘成为坐标流。 * 空白与其他不可映射字符直接丢弃。 */ int polybius_encrypt_file(const char *in_path, const char *out_path) { FILE *fin = fopen(in_path, "rb"); if (fin == NULL) { return -1; } FILE *fout = fopen(out_path, "wb"); if (fout == NULL) { fclose(fin); return -1; } char table[POLYBIUS_SIZE][POLYBIUS_SIZE]; polybius_init_table(table); int c; int first = 1; while ((c = fgetc(fin)) != EOF) { if (isspace((unsigned char)c)) { continue; /* 跳过换行、空格 */ } int row = 0; int col = 0; if (polybius_encode_char(table, (char)c, &row, &col) != 0) { continue; /* 标点、数字等全部忽略 */ } if (!first) { fputc(' ', fout); } fputc('0' + row, fout); fputc('0' + col, fout); first = 0; } fclose(fin); fclose(fout); return 0; }

first标志位用来控制坐标对之间的空格,避免文件开头多出一个前导空格。isspace的入参同样转成了unsigned char,和前面isalpha的理由相同。如果你要加密的文本包含数字或标点,棋盘本身就不支持,需要先约定好预处理规则,比如把数字转成英文单词再加密,否则解密回来的内容会缺一段。这是我用棋盘文件版踩过的最大的坑,好在代码注释里提前写了忽略规则,后面看输出才不懵。

5. 避坑与常见问题:五条实测踩坑记录

5.1 C语言取模结果居然是负数

现象:给凯撒解密传shift为-3,输出的内容前几个字母对,后面全乱。查打印结果发现偏移计算出现负的ASCII位移。原因:C语言的%是余数运算,结果符号跟随被除数,-3 % 26不是23而是-3。解决:所有入口先过normalize_shift,把移位规整到0-25;解密时再做一次负数补偿。从那以后我只要写带取模的字符位移,第一个函数永远是规整函数,没有例外。

5.2 isalpha传入中文字符导致未定义行为

现象:程序加密含中文的字符串时,偶尔崩溃或输出乱码,编译没任何警告。原因:char在常见平台是带符号类型,中文字符的ASCII扩展值超过127,传给isalpha时被解析成负数,进入标准库的未定义行为分支。解决:所有ctype.h的函数入参统一写成(unsigned char)c。这个坑很隐蔽,因为大多数时候不死机,只是结果偶尔不对,最容易当玄学处理。

5.3 J被棋盘合并成I,解密再也回不来

现象:原文里有个"JUST",棋盘加密解密后变成"IIST"或"IUST",J没了。原因:5×5棋盘只有25格,标准字母序盘面把J和I合并,加密时J被强制映射到I的坐标,这个信息损失发生在加密端,解密端无从还原。解决:要么接受有损,加密前主动用I替换J;要么改6×6扩展盘面。注意这不是代码bug,是算法本身的约束,文档里务必写明。

5.4 坐标串没有分隔符,切分全靠猜

现象:我把棋盘密文简化成"2315131334"这种紧凑串,然后解密失败,输出完全对不上。原因:坐标对没有分隔,两个坐标连在一起时,"23 15"和"2 31"都能被切出来,解码器无法确定边界。解决:要么保留空格分隔(推荐),要么把每个坐标强制写成固定两位且用非数字字符收尾。我这边最终采用了“空格分隔+定长两字符”的格式,人眼和程序都能稳定切分。

5.5 文本模式写文件,密文里多出一堆0D

现象:Windows下用"w"模式写棋盘密文文件,用十六进制工具看,每个0A前面都多了个0D,文件比预期大。原因:文本模式自动做了CRLF转换,\n变成\r\n。解决:文件读写统一用"rb"和"wb"。顺带提醒,如果加密的源文件本身是DOS格式,转成统一模式后解密会丢格式,最好先约定加密前把换行归一化成LF。

6. 进阶:组合加密链与自反性自动验证

6.1 先棋盘后凯撒的加密链怎么串

把两个密码串成一个组合链很自然:明文先棋盘加密成坐标流,再对坐标流做凯撒位移,解密反过来先凯撒后棋盘。因为凯撒只处理字母,而坐标流里的数字和空格都会原样保留,所以这套组合能正常工作。关键点在于凯撒的位移不能把坐标数字变成其他东西,数字在凯撒里不会被替换,这正是之前字符级函数保留非字母的设计意义。

6.2 用assert做往返测试

自反性测试是验证加解密代码最直接的手段:明文加密再解密,断言结果与原串一致。测试用例要避开J和标点,因为棋盘算法本身有损。我一般会准备一组只含A-Z和空格的字符串,跑一个批量循环,任何一个往返失败立刻定位是加密还是解密的问题。

#include <assert.h> #include <stdio.h> #include <string.h> void test_roundtrip(const char *plain) { char cipher[512]; char back[512]; polybius_encrypt_string(plain, cipher, sizeof(cipher)); polybius_decrypt_string(cipher, back, sizeof(back)); assert(strcmp(plain, back) == 0); } int main(void) { /* 测试用例刻意避开 J,棋盘盘面本身无法还原它 */ const char *cases[] = { "HELLO WORLD", "THE QUICK BROWN FOX", "A" }; for (int i = 0; i < 3; i++) { test_roundtrip(cases[i]); } printf("all roundtrip tests passed\n"); return 0; }

这里的assert在NDEBUG宏定义时会被整体移除,但用于开发期验证足够。如果要在生产逻辑里强制校验,换成显式的if判断更好。测试用例里加一个单字符"A"是为了验证边界长度,加一个长句是为了验证空格处理和多坐标对拼接。

6.3 凯撒移位扫描:密钥就只有25个,暴力走一遍

组合链里的凯撒层如果不知道移位,可以直接复用第二章的caesar_bruteforce,把坐标流逐位移位打印出来,人工挑选能看出坐标格式的那一行。这是古典密码的通用玩法:棋盘负责把字符打散,凯撒负责加一层位移,真正的强度并不高,但作为教学演示和课设答辩的验证步骤已经足够。我一般会在main函数里把加密、解密、往返测试和暴力扫描串成一个流程,跑一遍输出全链路结果,交作业时直接截图就能说明每个函数的职责。

从那以后我每次写这种古典密码组合,都会强制自己先写normalize_shift和缓冲区检查,再写核心逻辑;所有文件操作统一用二进制模式,所有ctype入参统一转unsigned char。这套习惯帮我省掉了后面调试的绝大部分麻烦,希望帮到你。完整代码和测试用例已经打包在下载资源里,gcc直接编译即可运行,对照着改参数就能看到不同位移和不同明文下的密文变化。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进