简介:面向刚接触C语言的开发者,这份资源解决了在终端中以类似MySQL查询结果的形式对齐打印含中文字符串的痛点。基于Ubuntu 14.04 64位环境实测,作者将字符串拼接、动态内存分配与释放、链表结点生成等基础操作整合为可复用的打印方案,并借助内存泄漏检测确保代码质量。压缩包共7个文件,约290KB,核心包含C源码与头文件、可执行程序、测试文本及两张运行效果截图,png可直观对比对齐效果,txt用于多样本输入验证。已有1097人学习或下载,适合想掌握中文对齐输出、需要参考完整C代码实现并了解内存管理细节的初学者。通过阅读源码与截图,读者可快速迁移到自身日志、报表等终端文本输出场景。1. C语言对齐打印中文表格:从“乱成一团”到“像MySQL一样整齐”大概每个写过C语言终端程序的人都碰到过同一个尴尬:想用printf打一张表格,英文数字整整齐齐,一旦混进中文字符,列瞬间就歪了。中文看着占两个英文字符的宽度,可sizeof算出来是两三个字节,printf的宽度控制按字节走,结果表头对不齐、内容参差不齐,斜线竖线横线拼在一起像一张被揉过的棋盘。这个问题在Linux终端、Windows控制台、嵌入式串口日志里都能见到,本质不是“中文字符本身乱”,而是“字符的字节长度”和“终端里真正占的格子数”被混为一谈。这篇文章要解决的就是这一件事:用纯C语言,写一个不依赖第三方库的表格打印函数,能够按字符的显示宽度对齐中英文混排内容,输出效果接近MySQL命令行查询结果那张分隔线+边框的样式。适合的读者是:正在做终端工具、日志系统、串口调试助手,或者课程设计里需要打印数据报表的人。你不需要懂什么高深算法,但需要对指针、字符串、多字节编码有一点基础。文中会给完整思路、可抄的代码、参数说明,以及我实际调试时踩过的五个坑。2. 对齐的前提:先把“字符”和“显示宽度”这两件事分开2.1 为什么printf默认对齐不了中文C语言里printf的%-10s这类宽度限定,指的是“字节数”,不是“屏幕上的列数”。一个英文字母是1字节,在终端里占1列;一个中文字符在UTF-8下是3字节,在终端里占2列;在GBK下是2字节,终端里照样占2列。也就是说,printf按字节算宽度,终端按字符画宽度,两个系统之间差了一倍甚至三倍,表格自然歪。我一开始也试过偷懒方案:把所有字符串统一转成GBK,因为GBK下每个汉字正好2字节,再用%*s配合strlen来算。看起来可行,但遇到一个致命问题——GBK是双字节编码,第二个字节的取值可能落在ASCII范围,如果你用strlen去数长度,遇到半个汉字就会数错;而且UTF-8环境下根本没有GBK可用,跨平台直接翻车。2.2 终端的“格子”:列宽度才是对齐的基本单位要想对齐,必须绕开“字节”这个概念,自己算“显示列数”。对你来说,一个中文字符算2列,英文、数字、半角标点算1列,全角标点算2列,Tab按一次跳到下一个制表位的那格算。C语言标准库里没有现成的函数直接返回“一个字符占几列”,所以需要自己写一个宽度判断函数。常见做法是:解析多字节字符,读出它的Unicode码点,然后判断码点落在哪个区间。如果是中日韩统一表意文字、全角标点、假名、谚文字母这些区间,就让它在表格里占2列;其余按1列处理。这个方案不依赖locale,也不用管终端到底用的是UTF-8还是GBK,只要你的源文件编码和终端编码一致,码点就能正确解析。下面是核心的基础函数,用来判断“一个字节序列从第几个字节开始,对应几个字节长度,以及它应该占几列”:#include <stdio.h> #include <stdlib.h> #include <string.h> // 返回一个多字节字符的字节数;0表示输入非法 int utf8_char_len(const char *s) { unsigned char c = (unsigned char)*s; if (c < 0x80) return 1; // ASCII if ((c & 0xE0) == 0xC0) return 2; // 2字节序列 if ((c & 0xF0) == 0xE0) return 3; // 3字节序列 if ((c & 0xF8) == 0xF0) return 4; // 4字节序列 return 0; } // 把UTF-8字节序列解析成Unicode码点 unsigned int utf8_to_codepoint(const char *s, int len) { unsigned char c = (unsigned char)*s; if (len == 1) return c; if (len == 2) return ((c & 0x1F) << 6) | ((unsigned char)s[1] & 0x3F); if (len == 3) return ((c & 0x0F) << 12) | (((unsigned char)s[1] & 0x3F) << 6) | ((unsigned char)s[2] & 0x3F); if (len == 4) return ((c & 0x07) << 18) | (((unsigned char)s[1] & 0x3F) << 12) | (((unsigned char)s[2] & 0x3F) << 6) | ((unsigned char)s[3] & 0x3F); return 0; }这段代码做两件事:给定一个字符串的首字节,判断这是个多字节字符(所以UTF-8下汉字是3字节),再把这个序列拼成Unicode码点。宽度判断的逻辑通常放在后面:如果码点落在0x4E00到0x9FFF区间,或0x3000到0x303F全角标点区间,就返回2列;否则返回1列。这比依赖wcwidth更可控,尤其是你在不同终端下测试时,标准库的wcwidth对某些字符的处理会出现不一致。2.3 不依赖libc宽字符函数的原因有人会问:“C语言不是有wchar_t和wprintf吗?”能用,但有不少坑。wchar_t在Linux下是4字节,在Windows下是2字节,两边对宽字符范围的定义不同,一套代码要写两份条件编译;wprintf和printf混用还会导致标准输出流进入宽字符模式,后续所有窄字符printf全部失效,必须重新fwide回窄模式,这个坑会在后面单独讲。自己解析UTF-8的最大好处是:只要源文件是UTF-8编码,你在任何支持UTF-8的终端上跑结果都一样,不用切换locale,也不用担心setlocale没调用导致中文乱码。3. 手写一个表格排版器:填充、边框、对齐三件套的C实现3.1 核心函数:按显示宽度补齐空格有了“字符占几列”的基础函数,下一步就是最核心的“对齐填充”。printf给不了你要的效果,因为%*s按字节宽度填充空格,汉字会被多算一倍。自己写一个pad_cell函数,把字符串按显示列数填充到指定宽度:// 返回字符串的显示宽度(中文算2,英文算1) int display_width(const char *s) { int w = 0; while (*s) { int len = utf8_char_len(s); if (len <= 0) { break; } unsigned int cp = utf8_to_codepoint(s, len); if (is_wide_char(cp)) { w += 2; } else { w += 1; } s += len; } return w; } // 将src按显示宽度width左对齐输出,不足补空格;返回实际输出的字节数 int pad_cell(const char *src, int width, char *dst, int dst_size) { int w = 0; int pos = 0; while (*src) { int len = utf8_char_len(src); if (len <= 0 || pos + len >= dst_size) break; unsigned int cp = utf8_to_codepoint(src, len); int cw = is_wide_char(cp) ? 2 : 1; if (w + cw > width) break; // 放不下这个字符,截断 memcpy(dst + pos, src, len); pos += len; w += cw; src += len; } dst[pos] = '\0'; memset(dst + pos, ' ', width - w); dst[pos + (width - w)] = '\0'; return pos + (width - w); }注意memset这行的作用:如果内容占了8列,目标宽度是12列,就补4个空格;如果内容本身就超过宽度,就截断到放得下的最后一个字符,再补到目标宽度。截断是按字符截的,不会出现半个UTF-8字符的残片——后面避坑章节里会专门说这个问题。is_wide_char是判断码点宽度的函数,按上面说过的区间写就行,这里不重复贴全量列表。这个函数的参数含义:src是要打印的原始字符串,width是你希望这个单元格占几列,dst是输出缓冲,dst_size是缓冲大小。我一般把它当底层原语来用,上层打印表格时就反复调用它填充每一格。3.2 画表格线:横线、竖线、交叉点的生成MySQL那种表格由三类线组成:水平分隔线,由+和-组成;单元格左右边界,由|包裹;表头与数据之间的分隔线,长度和单元格宽度严格对应。画线的关键就是:列宽数组先定下来,然后按列宽生成对应的横线串。// 生成表格横线,cols是每列宽度数组,col_count是列数 void print_separator(const int *cols, int col_count) { printf("+"); for (int i = 0; i < col_count; i++) { for (int j = 0; j < cols[i] + 2; j++) { // 左右各留1个空格 printf("-"); } printf("+"); } printf("\n"); } // 打印一行数据:values是字符串数组,cols是列宽数组 void print_row(const char *values[], const int *cols, int col_count) { printf("|"); for (int i = 0; i < col_count; i++) { char buf[256]; pad_cell(values[i], cols[i], buf, sizeof(buf)); printf(" %s |", buf); // 左侧再补1空格,让内容不贴边 } printf("\n"); }print_separator里cols[i] + 2的含义:每个单元格左右各留1个空格,所以横线长度是列宽+2。print_row里printf(" %s |", buf)前面那个空格就是左侧留白,buf里已经用pad_cell补足了内容后面的空格,所以右侧是“内容补空格+一个竖线”。这样画出来的表格,每列的实际占用是列宽+2,所有列宽相加再加上列数+1条竖线,就是整行宽度。3.3 自动测量列宽:先扫描数据再打印对齐的前提是“知道这一列最宽的内容是什么”。真实场景下没人会手填列宽,所以要先遍历一遍数据,把每列的最大显示宽度算出来,如果比表头还窄就取表头的宽度,再加一点余量。// 计算每列最大宽度,并加上2的余量 void compute_col_widths(const char *headers[], int col_count, const char *data[], int row_count, int *out_widths) { for (int c = 0; c < col_count; c++) { int maxw = display_width(headers[c]); for (int r = 0; r < row_count; r++) { int w = display_width(data[r * col_count + c]); if (w > maxw) maxw = w; } out_widths[c] = maxw; } }这里是按“行优先的扁平行列”访问数据的:data[r * col_count + c]。如果你手上的数据是链表或者结构体数组,改成对应的遍历方式就行,核心逻辑不变。加2的余量通常就够,如果表格太挤可以改成4。列宽确定后,先打印表头行,再打一条分隔线,然后逐行打印数据,最后再打一条底部横线。4. 中文字符宽度处理:多字节解析与补位规则4.1 全角标点、生僻字和特殊符号:宽度判断不能只查汉字很多教程里只把“中文字符”当成宽字符,遇到中文标点比如逗号、句号、引号就翻车了。全角逗号(,)在终端里占2列,它的码点是0xFF0C,不在0x4E00-0x9FFF区间;全角空格( )占2列,码点是0x3000。如果你只判断汉字区间,这些字符会被当成1列,打印出来就差一列,整行看起来还是歪。我一般用的判断区间是这几块:0x1100-0x115F(谚文) 、0x2E80-0xA4CF(中日韩部首和汉字)、0xAC00-0xD7A3(谚文字节)、0xF900-0xFAFF(兼容汉字)、0xFE30-0xFE4F(兼容标点)、0xFF00-0xFF60(全角ASCII与全角标点),再加上0x3000-0x303F。完整的宽字符列表要比这长,但对日常表格打印,这几块已经覆盖了绝大多数场景。4.2 宽度计算的边界:非法字节和中途截断真实数据里偶尔会出现非法UTF-8序列,比如从某个串口设备收来的二进制字节流,或者GBK文件被当成UTF-8读进来。utf8_char_len返回0的时候,我的策略是把该字节当作一个宽度为1的字符原样输出,并让指针前进一步。这样能保证表格不会因为一个坏字节就死循环或者整行乱掉。更关键的是“不要把字符切一半”。pad_cell里判断w + cw > width就 break,意思是“如果当前位置加上这个字符会超出列宽,就不输出这个字符”。如果换成逐字节拷贝,极可能在UTF-8的3字节序列中间截断,输出的字符串末尾会出现一个残缺字节,终端把它解读成乱码。这个问题后面避坑里还会细说。4.3 字节与显示宽度的换算实例来看一个典型的混合字符串对比:输入: "姓名 年龄" 字节数:strlen("姓名年龄") = 6 (UTF-8) 或 4 (GBK) 显示宽度:2+2+2+2 = 8如果是printf("%-8s", "姓名"),它按字节数8去补空格,实际上“姓名”只占了4列,会多补4个空格;另一列内容如果按同样的错觉对齐,就错位了。display_width算出来是4,pad_cell按4列处理,就不会多补。这样一行下来,每一列的起始位置才是准的。5. 避坑排查:五个经常翻车的对齐场景这一章记录我实际调试中遇到的高频问题,每条都是“现象→原因→解决”的结构,配置C语言中文表格打印时大概率能命中一两条。5.1printf("%*s")对中文字符串完全失效现象:用printf("%-*s", width, str)打印中文表格,列总是对不齐,看起来像随机多补或者少补了空格。原因:%*s的宽度参数是字节数,不是显示列数。UTF-8下“姓名”两个汉字是6字节,终端显示占4列,printf按照6去处理,自然错位。解决:不用printf的宽度修饰符,改用自己实现的pad_cell先填充空格,再printf("%s", buf)。上面的代码就是干这个的。这个坑最隐蔽的地方是纯英文数据测试时一切正常,一旦加入中文就全乱,容易让人误判是编码问题而不是宽度问题。5.2 终端编码和源文件编码不一致,表格内容乱码现象:代码里中文直接写的字符串字面量,编译后运行,中文变成乱码,表格线还正常。原因:编译器按源文件编码保存字符串字面量,终端按自身编码解析。比如源文件是GBK保存的,终端期望UTF-8,那中文全乱;反之亦然。Linux下一般建议源文件保存为UTF-8,Windows下较老的控制台默认GBK,两边不一样。setlocale(LC_ALL, "")能让标准库根据环境变量读取locale,但前提是系统已经配置了对应编码的locale;很多人没调用setlocale就输出中文,这在某些环境会触发“问号”故障。解决:统一编码是唯一出路。我在Linux下用UTF-8源文件+UTF-8终端;Windows下如果控制台是936代码页,要么把源文件存成GBK,要么在程序启动时调用SetConsoleOutputCP(CP_UTF8)改变控制台代码页。代码里不要写死编码判断,把is_wide_char设计成只看码点,码点从UTF-8解析得到,这样编码只跟源文件和终端相关,跟表格逻辑无关。5.3wprintf和printf混用导致输出顺序错乱现象:先用wprintf打印一行宽字符表头,再用printf打印数据,结果数据先于表头输出,或者干脆什么都不输出。原因:标准输出流被wprintf切换成了宽字符模式,之后的窄字符printf需要把窄字符转成宽字符再输出,但转码依赖locale设置,没设置locale时转换失败,导致输出缓冲被卡住。解决:整个程序统一使用窄字符printf+自实现的宽度填充,不使用wprintf。如果需要打印宽字符,预先转成UTF-8字节串再走printf。这个方案的额外好处是代码在Linux和Windows下行为一致,不需要#ifdef _WIN32处理宽字符类型。5.4 中英混排时看起来还是歪了一列现象:所有中文都在宽字符区间里,没有乱码,但某一列中英混排时总觉得差一个空格,表头与数据错位。原因:多半是宽字符判断区间不完整。全角标点、省略号、破折号、竖版引号这些不在汉字区间里,被当成了窄字符。比如“你好,世界”里那个全角逗号占2列,如果is_wide_char只判断0x4E00-0x9FFF,逗号被算作1列,这一整行的实际宽度就比预期少1,后面所有列向左偏移一格。解决:补全宽字符区间,尤其是0xFF00-0xFF60全半角区与0x3000-0x303F标点区。对绝大多数终端场景,把这两个区间加进去就不会再出现“差一列”的情况。如果还差,就写个小用例逐字符打印宽度,定位是哪个字符判断错了。5.5 截断时把UTF-8字符切半,表格末尾出现乱码现象:某列内容太长,被截断后末尾出现奇怪的符号,如“�”或者半个汉字。原因:按字节截断导致的。memcpy不知道字符边界,一个3字节的汉字可能被切成2字节+1字节,那个1字节残片被终端显示成乱码,更麻烦的是它可能让后续字节对齐判断错乱。解决:用pad_cell里的截断逻辑——按字符循环,如果加上当前字符会超过目标宽度,就停止,不输出半个字符。这样宁可少显示一个字,也不会破坏字符边界。这个规则对表格里可能出现超长内容(比如日志消息、路径字符串)非常重要。6. 扩展:自动列宽、截断省略号与其他对齐模式基础版表格已经能对齐了,但真实项目里通常还想要更多东西:超长内容能用省略号截断而不是硬切;列方向能左对齐也能右对齐;边框样式能自定义成+---+、|---|或空边框。这一章用一个小技巧整体收口:把填充函数升级成支持“右对齐”和“截断省略号”的通用版本,再做一次验证。右对齐的填充其实很简单:正常左对齐是把内容放最左边、空格补右边;右对齐就是把空格补左边、内容放右边。在pad_cell基础上加一个align参数,align为1表示右对齐,输出时先memset空格缓冲区,再把内容拷到缓冲区的末尾。截断省略号则是在“放不下字符”的分支里,如果还有2列以上的余量,补“..”或“...”。关键在于省略号本身也算显示宽度,两个点占2列,要先把空间预留出来。// 通用单元格填充:align=0左对齐,align=1右对齐;ellipsis=1表示超长时加省略号 int pad_cell_align(const char *src, int width, int align, int ellipsis, char *dst, int dst_size) { char tmp[256]; int tmp_len = 0; int w = 0; while (*src) { int len = utf8_char_len(src); if (len <= 0 || tmp_len + len >= (int)sizeof(tmp) - 4) break; unsigned int cp = utf8_to_codepoint(src, len); int cw = is_wide_char(cp) ? 2 : 1; if (w + cw > width) { if (ellipsis && width >= 3) { tmp[tmp_len++] = '.'; tmp[tmp_len++] = '.'; } break; } memcpy(tmp + tmp_len, src, len); tmp_len += len; w += cw; src += len; } tmp[tmp_len] = '\0'; // 按对齐方式填空格 int remain = width - w; memset(dst, ' ', width); if (align == 0) { memcpy(dst, tmp, tmp_len); } else { memcpy(dst + remain, tmp, tmp_len); } dst[width] = '\0'; return width; }参数含义:src是原始文本,width是单元格占的列数,align控制左对齐还是右对齐,ellipsis控制在超长时要不要加省略号,dst是输出缓冲区,dst_size防止越界。这个函数替代之前那个pad_cell后,整张表的所有单元格都能统一带对齐和省略号逻辑,避免在每一行里重复写填充代码。做完这些之后,验证步骤我一般固定跑三条用例:第一条全中文,表头数据都是4字以内,看列宽自动计算是否和手算一致;第二条中英混排,在某一列里塞一个长英文单词和一个全角标点;第三条超长截断,把一条100字节的日志塞进10列宽的单元格,确认结尾出现省略号而且没有乱码。用这三条用例覆盖“纯中文”“混排”“超长”三个最容易翻车的场景,就能放心收工。验证完成后,把这套逻辑丢进模拟项目X的日志打印模块里,替换掉原先那个用%*s硬拼的版本,输出从“对不齐但能看懂”变成了“可以截图当报告用”的水平。从那以后我每次写C语言终端表格,都会强制先跑一遍那三条验证用例,确认display_width和pad_cell的边界都符合预期再往上堆业务逻辑——省掉了无数次在排查“为什么这列又歪了”上浪费的时间。希望这篇笔记能帮你把中文表格打印这件事一次做对。本文还有配套的精品资源,点击获取
简介:面向刚接触C语言的开发者,这份资源解决了在终端中以类似MySQL查询结果的形式对齐打印含中文字符串的痛点。基于Ubuntu 14.04 64位环境实测,作者将字符串拼接、动态内存分配与释放、链表结点生成等基础操作整合为可复用的打印方案,并借助内存泄漏检测确保代码质量。压缩包共7个文件,约290KB,核心包含C源码与头文件、可执行程序、测试文本及两张运行效果截图,png可直观对比对齐效果,txt用于多样本输入验证。已有1097人学习或下载,适合想掌握中文对齐输出、需要参考完整C代码实现并了解内存管理细节的初学者。通过阅读源码与截图,读者可快速迁移到自身日志、报表等终端文本输出场景。1. C语言对齐打印中文表格:从“乱成一团”到“像MySQL一样整齐”大概每个写过C语言终端程序的人都碰到过同一个尴尬:想用printf打一张表格,英文数字整整齐齐,一旦混进中文字符,列瞬间就歪了。中文看着占两个英文字符的宽度,可sizeof算出来是两三个字节,printf的宽度控制按字节走,结果表头对不齐、内容参差不齐,斜线竖线横线拼在一起像一张被揉过的棋盘。这个问题在Linux终端、Windows控制台、嵌入式串口日志里都能见到,本质不是“中文字符本身乱”,而是“字符的字节长度”和“终端里真正占的格子数”被混为一谈。这篇文章要解决的就是这一件事:用纯C语言,写一个不依赖第三方库的表格打印函数,能够按字符的显示宽度对齐中英文混排内容,输出效果接近MySQL命令行查询结果那张分隔线+边框的样式。适合的读者是:正在做终端工具、日志系统、串口调试助手,或者课程设计里需要打印数据报表的人。你不需要懂什么高深算法,但需要对指针、字符串、多字节编码有一点基础。文中会给完整思路、可抄的代码、参数说明,以及我实际调试时踩过的五个坑。2. 对齐的前提:先把“字符”和“显示宽度”这两件事分开2.1 为什么printf默认对齐不了中文C语言里printf的%-10s这类宽度限定,指的是“字节数”,不是“屏幕上的列数”。一个英文字母是1字节,在终端里占1列;一个中文字符在UTF-8下是3字节,在终端里占2列;在GBK下是2字节,终端里照样占2列。也就是说,printf按字节算宽度,终端按字符画宽度,两个系统之间差了一倍甚至三倍,表格自然歪。我一开始也试过偷懒方案:把所有字符串统一转成GBK,因为GBK下每个汉字正好2字节,再用%*s配合strlen来算。看起来可行,但遇到一个致命问题——GBK是双字节编码,第二个字节的取值可能落在ASCII范围,如果你用strlen去数长度,遇到半个汉字就会数错;而且UTF-8环境下根本没有GBK可用,跨平台直接翻车。2.2 终端的“格子”:列宽度才是对齐的基本单位要想对齐,必须绕开“字节”这个概念,自己算“显示列数”。对你来说,一个中文字符算2列,英文、数字、半角标点算1列,全角标点算2列,Tab按一次跳到下一个制表位的那格算。C语言标准库里没有现成的函数直接返回“一个字符占几列”,所以需要自己写一个宽度判断函数。常见做法是:解析多字节字符,读出它的Unicode码点,然后判断码点落在哪个区间。如果是中日韩统一表意文字、全角标点、假名、谚文字母这些区间,就让它在表格里占2列;其余按1列处理。这个方案不依赖locale,也不用管终端到底用的是UTF-8还是GBK,只要你的源文件编码和终端编码一致,码点就能正确解析。下面是核心的基础函数,用来判断“一个字节序列从第几个字节开始,对应几个字节长度,以及它应该占几列”:#include <stdio.h> #include <stdlib.h> #include <string.h> // 返回一个多字节字符的字节数;0表示输入非法 int utf8_char_len(const char *s) { unsigned char c = (unsigned char)*s; if (c < 0x80) return 1; // ASCII if ((c & 0xE0) == 0xC0) return 2; // 2字节序列 if ((c & 0xF0) == 0xE0) return 3; // 3字节序列 if ((c & 0xF8) == 0xF0) return 4; // 4字节序列 return 0; } // 把UTF-8字节序列解析成Unicode码点 unsigned int utf8_to_codepoint(const char *s, int len) { unsigned char c = (unsigned char)*s; if (len == 1) return c; if (len == 2) return ((c & 0x1F) << 6) | ((unsigned char)s[1] & 0x3F); if (len == 3) return ((c & 0x0F) << 12) | (((unsigned char)s[1] & 0x3F) << 6) | ((unsigned char)s[2] & 0x3F); if (len == 4) return ((c & 0x07) << 18) | (((unsigned char)s[1] & 0x3F) << 12) | (((unsigned char)s[2] & 0x3F) << 6) | ((unsigned char)s[3] & 0x3F); return 0; }这段代码做两件事:给定一个字符串的首字节,判断这是个多字节字符(所以UTF-8下汉字是3字节),再把这个序列拼成Unicode码点。宽度判断的逻辑通常放在后面:如果码点落在0x4E00到0x9FFF区间,或0x3000到0x303F全角标点区间,就返回2列;否则返回1列。这比依赖wcwidth更可控,尤其是你在不同终端下测试时,标准库的wcwidth对某些字符的处理会出现不一致。2.3 不依赖libc宽字符函数的原因有人会问:“C语言不是有wchar_t和wprintf吗?”能用,但有不少坑。wchar_t在Linux下是4字节,在Windows下是2字节,两边对宽字符范围的定义不同,一套代码要写两份条件编译;wprintf和printf混用还会导致标准输出流进入宽字符模式,后续所有窄字符printf全部失效,必须重新fwide回窄模式,这个坑会在后面单独讲。自己解析UTF-8的最大好处是:只要源文件是UTF-8编码,你在任何支持UTF-8的终端上跑结果都一样,不用切换locale,也不用担心setlocale没调用导致中文乱码。3. 手写一个表格排版器:填充、边框、对齐三件套的C实现3.1 核心函数:按显示宽度补齐空格有了“字符占几列”的基础函数,下一步就是最核心的“对齐填充”。printf给不了你要的效果,因为%*s按字节宽度填充空格,汉字会被多算一倍。自己写一个pad_cell函数,把字符串按显示列数填充到指定宽度:// 返回字符串的显示宽度(中文算2,英文算1) int display_width(const char *s) { int w = 0; while (*s) { int len = utf8_char_len(s); if (len <= 0) { break; } unsigned int cp = utf8_to_codepoint(s, len); if (is_wide_char(cp)) { w += 2; } else { w += 1; } s += len; } return w; } // 将src按显示宽度width左对齐输出,不足补空格;返回实际输出的字节数 int pad_cell(const char *src, int width, char *dst, int dst_size) { int w = 0; int pos = 0; while (*src) { int len = utf8_char_len(src); if (len <= 0 || pos + len >= dst_size) break; unsigned int cp = utf8_to_codepoint(src, len); int cw = is_wide_char(cp) ? 2 : 1; if (w + cw > width) break; // 放不下这个字符,截断 memcpy(dst + pos, src, len); pos += len; w += cw; src += len; } dst[pos] = '\0'; memset(dst + pos, ' ', width - w); dst[pos + (width - w)] = '\0'; return pos + (width - w); }注意memset这行的作用:如果内容占了8列,目标宽度是12列,就补4个空格;如果内容本身就超过宽度,就截断到放得下的最后一个字符,再补到目标宽度。截断是按字符截的,不会出现半个UTF-8字符的残片——后面避坑章节里会专门说这个问题。is_wide_char是判断码点宽度的函数,按上面说过的区间写就行,这里不重复贴全量列表。这个函数的参数含义:src是要打印的原始字符串,width是你希望这个单元格占几列,dst是输出缓冲,dst_size是缓冲大小。我一般把它当底层原语来用,上层打印表格时就反复调用它填充每一格。3.2 画表格线:横线、竖线、交叉点的生成MySQL那种表格由三类线组成:水平分隔线,由+和-组成;单元格左右边界,由|包裹;表头与数据之间的分隔线,长度和单元格宽度严格对应。画线的关键就是:列宽数组先定下来,然后按列宽生成对应的横线串。// 生成表格横线,cols是每列宽度数组,col_count是列数 void print_separator(const int *cols, int col_count) { printf("+"); for (int i = 0; i < col_count; i++) { for (int j = 0; j < cols[i] + 2; j++) { // 左右各留1个空格 printf("-"); } printf("+"); } printf("\n"); } // 打印一行数据:values是字符串数组,cols是列宽数组 void print_row(const char *values[], const int *cols, int col_count) { printf("|"); for (int i = 0; i < col_count; i++) { char buf[256]; pad_cell(values[i], cols[i], buf, sizeof(buf)); printf(" %s |", buf); // 左侧再补1空格,让内容不贴边 } printf("\n"); }print_separator里cols[i] + 2的含义:每个单元格左右各留1个空格,所以横线长度是列宽+2。print_row里printf(" %s |", buf)前面那个空格就是左侧留白,buf里已经用pad_cell补足了内容后面的空格,所以右侧是“内容补空格+一个竖线”。这样画出来的表格,每列的实际占用是列宽+2,所有列宽相加再加上列数+1条竖线,就是整行宽度。3.3 自动测量列宽:先扫描数据再打印对齐的前提是“知道这一列最宽的内容是什么”。真实场景下没人会手填列宽,所以要先遍历一遍数据,把每列的最大显示宽度算出来,如果比表头还窄就取表头的宽度,再加一点余量。// 计算每列最大宽度,并加上2的余量 void compute_col_widths(const char *headers[], int col_count, const char *data[], int row_count, int *out_widths) { for (int c = 0; c < col_count; c++) { int maxw = display_width(headers[c]); for (int r = 0; r < row_count; r++) { int w = display_width(data[r * col_count + c]); if (w > maxw) maxw = w; } out_widths[c] = maxw; } }这里是按“行优先的扁平行列”访问数据的:data[r * col_count + c]。如果你手上的数据是链表或者结构体数组,改成对应的遍历方式就行,核心逻辑不变。加2的余量通常就够,如果表格太挤可以改成4。列宽确定后,先打印表头行,再打一条分隔线,然后逐行打印数据,最后再打一条底部横线。4. 中文字符宽度处理:多字节解析与补位规则4.1 全角标点、生僻字和特殊符号:宽度判断不能只查汉字很多教程里只把“中文字符”当成宽字符,遇到中文标点比如逗号、句号、引号就翻车了。全角逗号(,)在终端里占2列,它的码点是0xFF0C,不在0x4E00-0x9FFF区间;全角空格( )占2列,码点是0x3000。如果你只判断汉字区间,这些字符会被当成1列,打印出来就差一列,整行看起来还是歪。我一般用的判断区间是这几块:0x1100-0x115F(谚文) 、0x2E80-0xA4CF(中日韩部首和汉字)、0xAC00-0xD7A3(谚文字节)、0xF900-0xFAFF(兼容汉字)、0xFE30-0xFE4F(兼容标点)、0xFF00-0xFF60(全角ASCII与全角标点),再加上0x3000-0x303F。完整的宽字符列表要比这长,但对日常表格打印,这几块已经覆盖了绝大多数场景。4.2 宽度计算的边界:非法字节和中途截断真实数据里偶尔会出现非法UTF-8序列,比如从某个串口设备收来的二进制字节流,或者GBK文件被当成UTF-8读进来。utf8_char_len返回0的时候,我的策略是把该字节当作一个宽度为1的字符原样输出,并让指针前进一步。这样能保证表格不会因为一个坏字节就死循环或者整行乱掉。更关键的是“不要把字符切一半”。pad_cell里判断w + cw > width就 break,意思是“如果当前位置加上这个字符会超出列宽,就不输出这个字符”。如果换成逐字节拷贝,极可能在UTF-8的3字节序列中间截断,输出的字符串末尾会出现一个残缺字节,终端把它解读成乱码。这个问题后面避坑里还会细说。4.3 字节与显示宽度的换算实例来看一个典型的混合字符串对比:输入: "姓名 年龄" 字节数:strlen("姓名年龄") = 6 (UTF-8) 或 4 (GBK) 显示宽度:2+2+2+2 = 8如果是printf("%-8s", "姓名"),它按字节数8去补空格,实际上“姓名”只占了4列,会多补4个空格;另一列内容如果按同样的错觉对齐,就错位了。display_width算出来是4,pad_cell按4列处理,就不会多补。这样一行下来,每一列的起始位置才是准的。5. 避坑排查:五个经常翻车的对齐场景这一章记录我实际调试中遇到的高频问题,每条都是“现象→原因→解决”的结构,配置C语言中文表格打印时大概率能命中一两条。5.1printf("%*s")对中文字符串完全失效现象:用printf("%-*s", width, str)打印中文表格,列总是对不齐,看起来像随机多补或者少补了空格。原因:%*s的宽度参数是字节数,不是显示列数。UTF-8下“姓名”两个汉字是6字节,终端显示占4列,printf按照6去处理,自然错位。解决:不用printf的宽度修饰符,改用自己实现的pad_cell先填充空格,再printf("%s", buf)。上面的代码就是干这个的。这个坑最隐蔽的地方是纯英文数据测试时一切正常,一旦加入中文就全乱,容易让人误判是编码问题而不是宽度问题。5.2 终端编码和源文件编码不一致,表格内容乱码现象:代码里中文直接写的字符串字面量,编译后运行,中文变成乱码,表格线还正常。原因:编译器按源文件编码保存字符串字面量,终端按自身编码解析。比如源文件是GBK保存的,终端期望UTF-8,那中文全乱;反之亦然。Linux下一般建议源文件保存为UTF-8,Windows下较老的控制台默认GBK,两边不一样。setlocale(LC_ALL, "")能让标准库根据环境变量读取locale,但前提是系统已经配置了对应编码的locale;很多人没调用setlocale就输出中文,这在某些环境会触发“问号”故障。解决:统一编码是唯一出路。我在Linux下用UTF-8源文件+UTF-8终端;Windows下如果控制台是936代码页,要么把源文件存成GBK,要么在程序启动时调用SetConsoleOutputCP(CP_UTF8)改变控制台代码页。代码里不要写死编码判断,把is_wide_char设计成只看码点,码点从UTF-8解析得到,这样编码只跟源文件和终端相关,跟表格逻辑无关。5.3wprintf和printf混用导致输出顺序错乱现象:先用wprintf打印一行宽字符表头,再用printf打印数据,结果数据先于表头输出,或者干脆什么都不输出。原因:标准输出流被wprintf切换成了宽字符模式,之后的窄字符printf需要把窄字符转成宽字符再输出,但转码依赖locale设置,没设置locale时转换失败,导致输出缓冲被卡住。解决:整个程序统一使用窄字符printf+自实现的宽度填充,不使用wprintf。如果需要打印宽字符,预先转成UTF-8字节串再走printf。这个方案的额外好处是代码在Linux和Windows下行为一致,不需要#ifdef _WIN32处理宽字符类型。5.4 中英混排时看起来还是歪了一列现象:所有中文都在宽字符区间里,没有乱码,但某一列中英混排时总觉得差一个空格,表头与数据错位。原因:多半是宽字符判断区间不完整。全角标点、省略号、破折号、竖版引号这些不在汉字区间里,被当成了窄字符。比如“你好,世界”里那个全角逗号占2列,如果is_wide_char只判断0x4E00-0x9FFF,逗号被算作1列,这一整行的实际宽度就比预期少1,后面所有列向左偏移一格。解决:补全宽字符区间,尤其是0xFF00-0xFF60全半角区与0x3000-0x303F标点区。对绝大多数终端场景,把这两个区间加进去就不会再出现“差一列”的情况。如果还差,就写个小用例逐字符打印宽度,定位是哪个字符判断错了。5.5 截断时把UTF-8字符切半,表格末尾出现乱码现象:某列内容太长,被截断后末尾出现奇怪的符号,如“�”或者半个汉字。原因:按字节截断导致的。memcpy不知道字符边界,一个3字节的汉字可能被切成2字节+1字节,那个1字节残片被终端显示成乱码,更麻烦的是它可能让后续字节对齐判断错乱。解决:用pad_cell里的截断逻辑——按字符循环,如果加上当前字符会超过目标宽度,就停止,不输出半个字符。这样宁可少显示一个字,也不会破坏字符边界。这个规则对表格里可能出现超长内容(比如日志消息、路径字符串)非常重要。6. 扩展:自动列宽、截断省略号与其他对齐模式基础版表格已经能对齐了,但真实项目里通常还想要更多东西:超长内容能用省略号截断而不是硬切;列方向能左对齐也能右对齐;边框样式能自定义成+---+、|---|或空边框。这一章用一个小技巧整体收口:把填充函数升级成支持“右对齐”和“截断省略号”的通用版本,再做一次验证。右对齐的填充其实很简单:正常左对齐是把内容放最左边、空格补右边;右对齐就是把空格补左边、内容放右边。在pad_cell基础上加一个align参数,align为1表示右对齐,输出时先memset空格缓冲区,再把内容拷到缓冲区的末尾。截断省略号则是在“放不下字符”的分支里,如果还有2列以上的余量,补“..”或“...”。关键在于省略号本身也算显示宽度,两个点占2列,要先把空间预留出来。// 通用单元格填充:align=0左对齐,align=1右对齐;ellipsis=1表示超长时加省略号 int pad_cell_align(const char *src, int width, int align, int ellipsis, char *dst, int dst_size) { char tmp[256]; int tmp_len = 0; int w = 0; while (*src) { int len = utf8_char_len(src); if (len <= 0 || tmp_len + len >= (int)sizeof(tmp) - 4) break; unsigned int cp = utf8_to_codepoint(src, len); int cw = is_wide_char(cp) ? 2 : 1; if (w + cw > width) { if (ellipsis && width >= 3) { tmp[tmp_len++] = '.'; tmp[tmp_len++] = '.'; } break; } memcpy(tmp + tmp_len, src, len); tmp_len += len; w += cw; src += len; } tmp[tmp_len] = '\0'; // 按对齐方式填空格 int remain = width - w; memset(dst, ' ', width); if (align == 0) { memcpy(dst, tmp, tmp_len); } else { memcpy(dst + remain, tmp, tmp_len); } dst[width] = '\0'; return width; }参数含义:src是原始文本,width是单元格占的列数,align控制左对齐还是右对齐,ellipsis控制在超长时要不要加省略号,dst是输出缓冲区,dst_size防止越界。这个函数替代之前那个pad_cell后,整张表的所有单元格都能统一带对齐和省略号逻辑,避免在每一行里重复写填充代码。做完这些之后,验证步骤我一般固定跑三条用例:第一条全中文,表头数据都是4字以内,看列宽自动计算是否和手算一致;第二条中英混排,在某一列里塞一个长英文单词和一个全角标点;第三条超长截断,把一条100字节的日志塞进10列宽的单元格,确认结尾出现省略号而且没有乱码。用这三条用例覆盖“纯中文”“混排”“超长”三个最容易翻车的场景,就能放心收工。验证完成后,把这套逻辑丢进模拟项目X的日志打印模块里,替换掉原先那个用%*s硬拼的版本,输出从“对不齐但能看懂”变成了“可以截图当报告用”的水平。从那以后我每次写C语言终端表格,都会强制先跑一遍那三条验证用例,确认display_width和pad_cell的边界都符合预期再往上堆业务逻辑——省掉了无数次在排查“为什么这列又歪了”上浪费的时间。希望这篇笔记能帮你把中文表格打印这件事一次做对。本文还有配套的精品资源,点击获取