简介:面向C语言初学者的数制转换实例代码讲解,完整演示如何借助顺序栈实现十进制到八进制等进制的转换。资源以严蔚敏《数据结构》中的数制转换算法为蓝本,将伪代码转化为可运行、可调试的C程序,覆盖顺序栈的初始化、压栈、出栈、判空及输出等核心操作,并逐步解析“除基取余、逆序输出”的实现思路,适合正在学习栈结构或算法基础的同学对照练习。压缩包内仅有1个PDF文档,大小47KB,集中呈现代码全文和过程说明,便于打印或离线查看。目前已有894人学习,这份精简资料能帮助读者快速理解栈在后进先出场景中的应用,进而迁移到括号匹配、表达式求值等同类问题中。
1. 数制转换与栈:从一道课后题到C语言数据结构的经典组合
十进制 29 转成二进制是多少?你能心算成 11101,但要你写一段 C 语言数据结构风格的数制转换代码,还要扛住 0、负数、超大整数这些输入时,事情就没那么轻松了。数制转换实例代码看起来只是课后题,却是理解栈的一个最直接入口:取余法先算出低位、后算出高位,而栈的 LIFO 恰好把这一顺序倒过来,让“除基取余”和“逆序输出”变成同一件事。这篇笔记从原理讲到代码,再到调试场景和踩坑点,适合正在复习数据结构、准备笔试手写代码,或者做嵌入式与协议解析时想直接抄一段可靠代码的从业者。
2. 为什么取余法输出是反的:LIFO 与数制转换的天然对齐
2.1 取余法的余数序列,恰好和栈的弹栈顺序重合
十进制转二进制,最朴素的做法是除 2 取余。拿 29 举例,完整的推导是:29 / 2 = 14 余 1;14 / 2 = 7 余 0;7 / 2 = 3 余 1;3 / 2 = 1 余 1;1 / 2 = 0 余 1。余数序列从第一次到最后一次依次是 1、0、1、1、1。如果顺着这个顺序拼成“10111”,答案就错了,因为第一次除出来的余数其实是二进制的最低位,也就是 2 的 0 次方位,最后一次余数才是最高位。正确输出必须从最后一位余数往回想,得到 11101。
这正是栈的用武之地。每算出一个余数就执行一次 push,全部算完后从栈顶开始弹栈,弹出的顺序天然是“最后一次余数、倒数第二次余数……第一次余数”,和正确输出顺序完全一致。也就是说,用栈做数制转换时,不需要额外的数组反转逻辑,也不需要记住有效长度再倒序遍历,弹栈本身就是逆序输出。这也是为什么数据结构教材几乎不约而同把数制转换放在栈这一章:它不是一道需要复杂算法的题,而是用最少的代码把栈的 LIFO 特性讲透。
动手实现前,有一个和栈顶方向相关的细节容易绕晕。顺序栈用数组模拟,栈底固定在数组下标 0,top 指向当前栈顶元素,空栈时 top = -1;push 时先让 top 自增再写入数据,pop 时先取出 data[top] 再让 top 自减。这个约定贯穿后面的完整代码。只要把 top 的初始值或者指针方向搞反,弹栈顺序就会错得不着痕迹,程序不崩,但转换结果全错。
2.2 数组、递归、栈三种实现对比:栈不是唯一解,但最省心
不用栈也能完成数制转换。最直接的数组方案是:定义 int result[64],用一个下标 i 记录写入位置,while 循环里 result[i++] = n % base,同时 n /= base,最后从 i - 1 倒着遍历打印。逻辑完全正确,写起来也只多三步。但问题在于“倒序遍历”这个动作需要自己维护边界:循环条件是 j >= 0 而不是 j > 0,下标是从 i - 1 开始而不是从 i 开始,一旦写错,要么丢最高位,要么越界访问。这类错误在白板笔试里非常隐蔽,因为打印出来的结果“好像对,又好像少了一位”。
递归版也非常常见:convert(n / base, base); 然后打印 n % base。递归天然具有“先递进、后回归”的顺序,回归阶段打印余数,正好是逆序。而且代码比栈版更短。但递归的代价是依赖系统函数调用栈:递归深度等于转换结果的位数,十进制 int 最大值转二进制也不过 32 层,看起来很安全,可一旦换成大数字符串转二进制,深度可能上千层,直接栈溢出崩溃。加上每层递归都有函数调用开销,在循环调用转换函数的场景下性能也不占优。
综合来看,显式栈版本最稳。它不依赖系统栈深度,数据放在自己管理的数组或链式节点里,行为可预期,出错也容易定位。实际工程里我一般首选顺序栈:写起来短、运行快、内存连续;只有在输入规模无法预估时才换链栈。这个选型在后面第 3 章和第 4 章的大数场景会再展开。
2.3 二进制、八进制、十六进制共用同一套除基取余逻辑
数制转换最常见的误区是把二进制、八进制、十六进制当成三套不同的算法。其实它们的核心都是同一个公式:n % base 得到当前最低位,n /= base 去掉这一位,重复直到 n 为 0。区别只在 base 的取值,以及余数大于 9 时如何映射到字母。
base = 2,每次取余结果只有 0 或 1;base = 8,余数范围 0~7;base = 16,余数范围 0~15,其中 10~15 要显示成 A~F。进而言之,base 可以扩展到 36,用 0~9 加 A~Z 表示全部余数。这意味着一个通用函数 void convertToBase(int num, int base) 就能覆盖从二进制到三十六进制的所有情况,调用方只需要传入不同的 base。
这种统一性对代码设计很有价值。不要在 main 里为二进制和十六进制各写一个独立函数,而要让转换函数只依赖参数 base,把字符映射交给一张表。这样新增进制不需要改逻辑,只需要确认 DIGITS 表覆盖到对应长度。后面第 6 章会专门讲这张映射表的写法,它能把 if-else 分支彻底删掉。
3. 用 C 语言写数制转换实例:从类型定义到完整可运行代码
3.1 顺序栈定义与五个基础操作
以下代码按“数组模拟栈”实现,适合笔试和大多数日常工具场景。首先是栈的类型定义和基础操作,这部分是所有栈应用共用的一段,单独拎出来方便复用。
#include <stdio.h> #include <string.h> #define STACK_SIZE 64 #define BASE_MAX 36 /* 顺序栈:data数组存余数,top指向栈顶元素 */ typedef struct { int data[STACK_SIZE]; int top; } Stack; void initStack(Stack *s) { s->top = -1; /* 空栈约定为 -1 */ } int isStackEmpty(Stack *s) { return s->top == -1; } int isStackFull(Stack *s) { return s->top == STACK_SIZE - 1; } int push(Stack *s, int val) { if (isStackFull(s)) { return 0; /* 栈满则失败,由调用方决定怎么处理 */ } s->data[++(s->top)] = val; return 1; } int pop(Stack *s, int *val) { if (isStackEmpty(s)) { return 0; /* 栈空则失败 */ } *val = s->data[(s->top)--]; return 1; }这段代码的要点有三个。第一,data 数组元素类型用 int 而不用 char,虽然余数最大只有 35,但 int 语义更清晰,也不容易出现符号位相关的隐式转换问题。第二,push 和 pop 都带返回值,调用方必须检查返回值,这是后面排查栈满问题的关键设计。第三,top 初始化为 -1 后,push 先自增再写,pop 先读再自减,两个操作对称,不需要额外记录“元素个数”这个变量。
3.2 核心 convert 函数:进栈、弹栈、字符映射一条龙
转换函数接受四个参数:原始整数、目标进制、输出缓冲区、缓冲区大小。缓冲区大小这个参数非常重要,C 语言里字符串拷贝和拼接最容易出事的点就是越界写,后面第 5 章的避坑会专门讲。
static const char DIGITS[] = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; void convertToBase(int num, int base, char *out, int outSize) { Stack s; initStack(&s); if (base < 2 || base > BASE_MAX) { out[0] = '\0'; return; } if (num == 0) { /* 0 的特判不能省 */ out[0] = '0'; out[1] = '\0'; return; } int n = num; if (n < 0) { /* 负数先输出负号,再转绝对值 */ out[0] = '-'; out[1] = '\0'; n = -n; /* 注意:num 为 INT_MIN 时这里会溢出,见第5章 */ } while (n > 0) { if (!push(&s, n % base)) { /* 栈满必须判失败 */ out[0] = '\0'; return; } n /= base; } char *p = out + (out[0] == '-' ? 1 : 0); while (pop(&s, &n)) { if (p - out < outSize - 1) { /* 预留一个位置给 '\0' */ *p++ = DIGITS[n]; } else { break; } } *p = '\0'; }逻辑分三段看。第一段做参数校验和 0 特判,base 不合法直接返回空串,num 为 0 直接写字符 '0',避免进入 while 循环后什么都不做。第二段是进栈循环,每轮取 n % base 压栈,再把 n 除以 base,直到 n 变成 0。第三段是弹栈循环,每次弹出一个余数,用 DIGITS 表映射成字符写入输出缓冲区,同时用 p - out 判断剩余空间,防止越界。
参数说明:outSize 必须是调用方传入的缓冲区真实大小,函数内部不做“缓冲区有多大”的假设;DIGITS 表的长度是 36,下标 0~9 对应字符 '0'~'9',下标 10~35 对应 'A'~'Z'。如果后续只支持到十六进制,这张表可以只保留到 'F',但保留完整表能让函数直接支持任意 2~36 进制,测试时也更方便。
3.3 完整 demo 与运行效果
有了上面的函数,写一个 main 验证各种输入是顺手的事。测试用例建议覆盖:普通正数、8 和 16 进制、0、负数、以及进制超过 10 的情况。
int main(void) { char buf[128]; int inputs[] = {29, 255, 0, -29, 1000}; int bases[] = {2, 8, 16, 2, 36}; for (int i = 0; i < 5; i++) { convertToBase(inputs[i], bases[i], buf, sizeof(buf)); printf("convert(%d, %d) = %s\n", inputs[i], bases[i], buf); } return 0; }运行结果如下:
convert(29, 2) = 11101 convert(255, 8) = 377 convert(0, 16) = 0 convert(-29, 2) = -11101 convert(1000, 36) = RS其中 1000 转三十六进制值得验证一下:1000 / 36 = 27 余 28,27 对应字符 'R',28 对应字符 'S',结果是 RS,符合 DIGITS 表的映射规则。demo 里故意把 0 和负数混进来,是因为这两类输入最容易在边界处翻车,后面第 5 章会详细说明它们各自坑在哪。
3.4 链栈版改动点:应付动态数据的另一个选择
顺序栈的容量固定为 64,对 int 转二进制完全够用,因为 32 位 int 的二进制最多 32 位,加上负号和结束符也不会超过 64。但如果输入扩展到大数字符串,位数可能成百上千,顺序栈就必须预先分配很大空间,否则要动态扩容。另一个选择是链栈,节点动态分配,容量只受堆内存限制。
typedef struct Node { int data; struct Node *next; } Node; typedef struct { Node *top; } LinkStack; int pushLink(LinkStack *s, int val) { Node *newNode = (Node *)malloc(sizeof(Node)); if (newNode == NULL) { return 0; } newNode->data = val; newNode->next = s->top; s->top = newNode; return 1; } int popLink(LinkStack *s, int *val) { if (s->top == NULL) { return 0; } Node *tmp = s->top; *val = tmp->data; s->top = tmp->next; free(tmp); return 1; }链栈的核心逻辑和顺序栈完全一致,差别只在 push 时 malloc 新节点、pop 时 free 旧节点。使用链栈的代价是每次 push 都有一次堆内存分配,频繁转换时性能不如顺序栈;收益是栈深不受固定数组限制。在笔试题里,顺序栈通常已经够用;但如果题目明确说“数字可能特别大”或“转换结果位数不确定”,果断选链栈,能少改很多代码。
4. 从课后题到调试现场:数制转换在内存与寄存器里的实际用途
4.1 二进制转十六进制:内存 dump 最常遇到的换算
调试内存时,调试器展示的通常是十六进制字节,比如 0x1F、0x3A。而你在看协议报文、寄存器 dump 或者二进制文件时,脑子里反复做的其实是“每 4 个二进制位合 1 个十六进制位”的转换。这种场景下不需要除基取余的通用函数,而是要把一个字节拆成高 4 位和低 4 位,分别查表映射成字符。
static const char HEX[] = "0123456789ABCDEF"; void printHex(const unsigned char *buf, int len) { for (int i = 0; i < len; i++) { putchar(HEX[buf[i] >> 4]); /* 高4位 */ putchar(HEX[buf[i] & 0x0F]); /* 低4位 */ if ((i + 1) % 16 == 0) { putchar('\n'); } else { putchar(' '); } } putchar('\n'); }buf[i] >> 4 取出高 4 位,范围 0~15,直接作为 HEX 表下标;buf[i] & 0x0F 屏蔽高 4 位,留下低 4 位。这个函数没有除法也没有取模,全部用位运算完成,速度很快,适合在日志里周期性打印报文内容。它和通用数制转换是同一套思维:进制转换本质是“用目标进制重新表示同一个数值”,而十六进制正好是二进制按 4 位分组后的缩写。
4.2 寄存器位域换算:嵌入式开发里的高频重复劳动
芯片手册里的寄存器经常是位域定义,比如 bit[3:0] 是分频系数,bit[5:4] 是输入源选择。调试时要往寄存器里写一个值,比如“输入源 = 2,分频 = 9”,换算成十六进制就是 0x29。反过来,读回 0x29,要心算出来 bit[5:4] 是 2、bit[3:0] 是 9。这个换算做多了之后,人会自动借用二进制中间层:2 的二进制是 10,9 的二进制是 1001,拼起来是 100101,再转十六进制就是 0x29。
代码层面,经常需要的是“二进制字符串转数值”的小工具,配合位运算把多个位域拼成一个寄存器值:
unsigned int binToUInt(const char *bin) { unsigned int val = 0; while (*bin) { val = (val << 1) | (*bin - '0'); bin++; } return val; } void printBin(unsigned int n, int bits) { for (int i = bits - 1; i >= 0; i--) { putchar((n >> i) & 1U ? '1' : '0'); } putchar('\n'); }binToUInt 从左往右扫描字符串,每读到一个字符就把当前值左移一位再拼上新位,符合二进制串“高位在前”的书写习惯。printBin 是它的逆操作:从最高位开始逐位判断,用 (n >> i) & 1 取出第 i 位。这两个函数配合前面的 printHex,就能在三种表示之间来回切换,调试寄存器时非常顺手。
这里要特别区分一个概念:printBin 打印的是计算机内部的补码表示。对 unsigned int 来说,-29 会被当成一个很大的正数,打印出来是一长串 1 和 0;而第 3 章的 convertToBase 输出的是数学意义上的符号加绝对值。同一个数,两种输出结果完全不同,但它们都是正确的数制转换,只是出发角度不一样。做协议解析时先想清楚你要的是“数学表示”还是“内存表示”。
4.3 大数场景:超过 unsigned long long 时栈存什么
工业场景里偶尔会遇到“一个很长的十进制字符串要转成二进制或十六进制”的需求,比如处理超大配置项或算法题的进阶变体。C 语言里 unsigned long long 最大只到约 1.8 × 10^19,几十位数字就超出范围,不能用整数变量直接计算。这时候仍然可以用除 2 取余的思路,只是“除以 2”变成了“大数除法”。
核心步骤是:把十进制数字符串当成一个用字符数组表示的大整数,从左往右每一位依次计算 cur = rem * 10 + digit,cur / 2 作为当前位的商,cur % 2 作为向下一位传递的余数。这一轮结束后,整个字符串变成了除以 2 之后的商,而最终的 rem(也就是处理最高位后剩下的余数)就是整个大数除以 2 的结果,即当前二进制最低位。
char quotient[512]; /* 本轮除法得到的商 */ int rem = 0; /* 余数,同时作为向低位传递的进位 */ int len = strlen(dec); for (int i = 0; i < len; i++) { int cur = rem * 10 + (dec[i] - '0'); quotient[i] = '0' + cur / 2; rem = cur % 2; } /* 此时 quotient 是 dec 除以 2 的商,rem 是本轮二进制位 */每一轮循环产生一个 rem,依次是二进制的最低位、次低位、次次低位。这些位收集起来之后,仍然需要逆序输出,所以栈又派上用场:每轮把 rem push 进栈,等商变成字符串 "0" 时停止,最后统一弹栈输出。位数可能上千,顺序栈的 64 容量不够,所以要么把 STACK_SIZE 调大,要么直接用第 3.4 节的链栈。这也是为什么前面强调链栈不是摆设,它是大数场景的刚需。
5. 数制转换实例的五个常见坑:现象、原因与修复
5.1 输出缓冲区越界写入:排查起来像玄学
现象:转换大数时,printf 打印转换结果后字符串被截断,或者结果后面跟着一串乱码;更严重时程序直接崩溃。原因:弹栈循环只判断栈是否为空,没有判断输出缓冲区还剩多少空间。C 语言里字符串写入没有自动边界检查,out[idx++] 一路写下去,迟早踩到相邻内存。解决:convert 函数必须接收 outSize 参数,每次写入前用当前下标和 outSize 比较,留一个位置给字符串结束符 '\0'。上面第 3 章代码里的 if (p - out < outSize - 1) 就是这个检查。
5.2 栈满被静默忽略:转换结果少了一段最高位
现象:输入一个很大的数或者需要很长结果时,输出看起来“短了一截”,但程序不报错。比如某次把大数字符串转二进制,结果是 64 位,但输出只有 40 位。原因:顺序栈容量固定,push 返回 0 表示栈满,但很多人不检查返回值,或者只在 push 内部做了判断就继续往下执行。栈里的数据被丢弃,弹栈时自然少了后半段。解决:每次 push 后都要判断返回值,栈满时立刻返回空串或错误码,而不是继续弹栈。更彻底的方案是换链栈,容量不固定,能从根上消除这个隐患。
5.3 十六进制结果全是数字:10~15 没有映射成 A~F
现象:255 转十六进制,期望输出 FF,实际输出可能是 1515,或者一堆问号。原因:把余数直接当成字符打印,比如 printf("%d", digit),数字 15 打出来还是 15,不会自动变成 'F';又或者用了 digit + '0' 的方式映射,10 加 '0' 得到 58,在 ASCII 表里是冒号。解决:用一张 DIGITS 表做映射,余数作为数组下标,DIGITS[15] 直接就是 'F'。这也是第 3 章和第 6 章反复强调查表法的原因:它把最容易出错的映射逻辑压缩成一行。
5.4 0 和负数输入:一段被低估的边界处理
现象:convertToBase(0, 2) 返回空字符串,因为 while (n > 0) 一次都不执行;convertToBase(-29, 2) 输出乱码或空串。原因:0 的情况没有单独处理;负数在 C 语言里取模结果与被除数同号,-29 % 2 得到 -1,DIGITS[-1] 是越界访问,行为未定义。解决:函数开头先特判 num == 0 输出字符 '0';负数先输出 '-',再对绝对值执行正常转换。还要注意 int 的最小值取反会溢出,工程上如果必须支持 INT_MIN,建议改用 unsigned int 或者 long long 保存绝对值后再转换。
5.5 栈没初始化:top 是随机值,数据写错位置
现象:同一份代码在某些编译器上运行正常,换一台机器后转换结果完全错乱,甚至第一次 push 就段错误。原因:声明 Stack s; 后没有调用 initStack(&s),top 是栈上的随机值。如果随机值是 100,push 时直接写 s.data[100],超出数组边界,破坏相邻内存。解决:在 convertToBase 函数的第一行调用 initStack(&s),把 top 明确设为 -1。这个问题在白板笔试里特别常见,属于必须记住的血泪经验:任何栈在使用前,先初始化。
6. 一个让转换更顺手的技巧:查表法加位运算
6.1 用 DIGITS 表做余数映射,删掉 if-else 分支
第 3 章的 convertToBase 里,DIGITS 表把余数映射压缩成了一行代码。很多初学者第一次写十六进制转换时,会写 if (digit < 10) out[i] = '0' + digit; else out[i] = 'A' + digit - 10;。这个写法没错,但每弹出一个余数就要判断一次,代码看起来也啰嗦。换成 char DIGITS[] = "0123456789ABCDEF"; 之后,out[i] = DIGITS[digit] 一行完成。表驱动的方式还有一个好处:后续想把进制扩展到 36,只需要把字符串加长到 "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ",转换函数本身一行都不用改。
6.2 基数为 2 的幂时,用移位和掩码替代除法
除法和取模在编译器眼里不便宜,尤其在一个大循环里反复调用时,性能差异可以感知。如果目标进制是 2、4、8、16 这类 2 的幂,可以用位运算直接替换:n % 16 等价于 n & 15,n / 16 等价于 n >> 4。以十六进制为例,while (n > 0) 里取余数改成 n & 0x0F,缩小数字改成 n >>= 4,效果完全一样,执行更快。这个优化在 printHex 里已经很自然地在用了,buf[i] >> 4 和 buf[i] & 0x0F 就是同一套思路。
不过要提醒一点:位运算只在 base 是 2 的幂时才成立,base 为 10 或任意奇数进制时,除法和取模仍然是唯一选择。我自己的习惯是:写通用工具函数时老老实实用除法,保证任何进制都能跑;只有在性能敏感路径上,且进制确定为 16 或 8 时,才切换到位运算版本。每次写完转换代码,我都会把 0、负数、大数这三类输入跑一遍回归,确认在改优化前后结果一致。这个习惯帮我挡过好几次“优化完输出全变”的意外,希望能帮到你。
本文还有配套的精品资源,点击获取