很多刚把 C 语言语法过完一遍的人都会卡在同一个尴尬的位置循环、数组、函数指针都认识了可一旦想写点看得见的东西脑子里第一反应还是printf(Hello World)。命令行里除了滚动的文字好像就没什么可玩的了。其实终端远比你想象的能画——不用任何图形库、不碰任何窗口系统光靠stdout和字符就能把一条函数曲线打印出来。这篇东西就是聊这个用纯 C 在标准输出上绘制简单的函数图像涉及坐标映射、采样、字符画布、抗锯齿取舍这些实打实的细节。它适合已经能写基本 C 程序、想找个练手项目把指针和数组用起来的人也适合想给命令行工具加一点可视化能力的老手参考。下面我按自己实际动手的顺序把踩过的坑和想明白的道理一条条摊开讲。1. 为什么终端画图这件事值得认真做一遍1.1 终端其实是一块低分辨率的位图先把一个认知摆正终端不是只能显示文字的黑盒它本质上是一块字符网格每个格子能放一个字符、带一个前景色。你完全可以把这块网格当成一张分辨率极低的位图来用——宽 80 列、高 24 行就是一张 80×24 的画布。这个视角一转换绘制函数图像的问题立刻变成了两个子问题怎么把数学坐标系里的点映射到这块字符网格上以及每个格子该填什么字符。我见过不少人一上来就想找终端图形库其实对于简单函数曲线完全没必要。字符画布的好处是零依赖、跨平台、输出可重定向到文件、还能直接贴进日志里。代价是分辨率低一条平滑曲线在 24 行的高度里会显得很锯齿。但恰恰是这个限制逼着你去理解采样、量化、映射这些底层概念这些概念在真正的图形编程里一个都跑不掉。所以这个项目的价值不在于画得多好看而在于它是一个完整的、闭环的小系统输入是一个数学函数输出是一屏字符中间要处理坐标变换、边界裁剪、采样密度、字符选择。麻雀虽小五脏俱全。1.2 一个最小可运行版本长什么样在展开细节之前先给你一个能跑起来的最小骨架让你对整体形状有个概念。核心思路是遍历画布的每一行每一列把屏幕坐标反算成数学坐标代入函数求值再判断这个值是否落在当前行对应的区间里。#include stdio.h #include math.h #define W 79 #define H 23 int main(void) { char canvas[H][W]; /* 初始化画布为空格 */ for (int y 0; y H; y) for (int x 0; x W; x) canvas[y][x] ; /* 数学坐标范围 */ double xmin -3.14159, xmax 3.14159; double ymin -1.2, ymax 1.2; /* 逐列采样把函数值映射到行 */ for (int col 0; col W; col) { double mx xmin (xmax - xmin) * col / (W - 1); double my sin(mx); int row (int)round((ymax - my) / (ymax - ymin) * (H - 1)); if (row 0 row H) canvas[row][col] *; } /* 输出 */ for (int y 0; y H; y) { for (int x 0; x W; x) putchar(canvas[y][x]); putchar(\n); } return 0; }这段代码不到四十行但已经把整个流程走通了。注意几个关键点(ymax - my)而不是(my - ymin)是因为屏幕的行号是自上而下递增的而数学的 y 轴是自下而上递增的这里必须翻转round而不是直接截断是为了让曲线落在最近的整数行上减少系统性偏移。这两处如果搞反画出来的图会上下颠倒或者整体偏一格是新手最容易栽的地方。2. 坐标映射整个项目里最容易出错的一环2.1 屏幕坐标与数学坐标的双向换算坐标映射是这个项目的核心也是最容易出 bug 的地方。我建议你把两个方向的换算公式都显式写出来别偷懒用同一个表达式硬套。从数学坐标到屏幕坐标用于把函数值落到某一行screen_row (ymax - math_y) / (ymax - ymin) * (H - 1) screen_col (math_x - xmin) / (xmax - xmin) * (W - 1)从屏幕坐标到数学坐标用于逐列采样时反算 xmath_x xmin (xmax - xmin) * screen_col / (W - 1) math_y ymax - (ymax - ymin) * screen_row / (H - 1)为什么分母是W - 1和H - 1而不是W和H因为屏幕坐标是离散的整数第 0 列对应xmin第W-1列对应xmax中间均匀分布。如果你用W做分母那么xmax永远取不到曲线右端会缺一截而且整体会有一个微小的缩放偏差。这个细节在画正弦这种两端对称的函数时特别明显——用W做分母右半边的波形会比左半边挤一点。我个人的习惯是把映射封装成两个小函数参数用结构体传避免到处复制粘贴公式typedef struct { double xmin, xmax, ymin, ymax; int w, h; } Viewport; int map_x(const Viewport *vp, double mx) { return (int)round((mx - vp-xmin) / (vp-xmax - vp-xmin) * (vp-w - 1)); } int map_y(const Viewport *vp, double my) { return (int)round((vp-ymax - my) / (vp-ymax - vp-ymin) * (vp-h - 1)); }封装的好处是当你后面想加坐标轴、想加刻度标签时所有地方都调同一套映射不会出现曲线和坐标轴对不齐这种诡异问题。2.2 宽高比失真为什么你的正弦看起来像被压扁了这是终端画图最隐蔽的坑。字符网格的每个格子不是正方形——一个字符通常高大约是宽的两倍。也就是说80 列 × 24 行的画布物理上大概是 80 宽 × 48 高的比例而不是 80×24。如果你直接按数学比例映射画出来的圆会变成椭圆正弦波会被纵向压扁。解决办法有两个。第一种是调整数学坐标范围让(xmax - xmin) / (ymax - ymin)大致等于W / (H * 2)人为把 y 范围拉大。第二种是接受失真但在心里清楚这是终端特性不追求几何精确。我一般选第一种因为画出来的形状更符合直觉。举个具体例子画y sin(x)x 取[-π, π]那么 x 跨度约 6.28。如果画布是 79×23按字符宽高比 1:2 折算等效高度是 46那么 y 跨度应该取6.28 * 23 / (79 * 2) ≈ 0.91也就是ymin -0.91, ymax 0.91左右。这样画出来的正弦波振幅和波长比例才接近真实。如果你直接用[-1.2, 1.2]波形会显得又矮又胖。提示宽高比这件事没有绝对正确的答案取决于你的终端字体。稳妥的做法是先画一个已知形状比如圆或者 45 度直线校准一下看看在你的终端里字符的实际宽高比是多少再回头调参数。2.3 边界裁剪与越界防护函数值超出[ymin, ymax]是家常便饭比如tan(x)在极点附近会冲到无穷大1/x在 0 附近也是。如果你不做防护map_y算出来的行号可能是负数或者大于H-1直接拿去索引canvas[row][col]就是越界写轻则花屏重则崩溃。防护分两层。第一层在映射之后判断行号是否在[0, H-1]内不在就丢弃这个点。第二层更稳妥在采样阶段就检查函数值是否为NaN或Inf——isnan()和isinf()这两个宏在math.h里现成的别省这一步。我踩过一次坑某次画log(x)x 取到负数时返回NaNround(NaN)转成 int 是未定义行为在某些平台上直接得到一个巨大的负数然后就越界了。加上isnan判断之后问题消失。double my f(mx); if (isnan(my) || isinf(my)) continue; int row map_y(vp, my); if (row 0 || row vp.h) continue; canvas[row][col] *;这段防护看起来啰嗦但它能让你放心地去画各种危险函数而不用每次换函数都提心吊胆。3. 采样策略稀疏、密集与断点处理3.1 逐列采样为什么通常够用最简单的采样方式是逐列采样对画布的每一列反算出一个数学 x求一次函数值落到一行上。这种方式的采样点数是W对于 79 列的终端来说就是 79 个点。对于sin、cos、多项式这类平缓函数79 个点足够勾勒出形状因为相邻两列的 x 间隔很小函数值变化不大落点会连成一条视觉上连续的线。但逐列采样有个前提函数在每一列上最多只有一个显著的值。对于单调或者缓变的函数这成立但对于剧烈振荡的函数比如sin(50x)就不成立了——一列之内函数可能上下翻好几次逐列采样会漏掉大量细节画出来是一团乱码似的点。这时候要么加密采样每列采多个点要么干脆承认终端分辨率不够换个频率低点的函数来演示。我的经验是先用逐列采样跑通如果发现曲线断断续续或者跳变再考虑加密。不要一上来就搞复杂采样那样调试成本高而且大部分教学场景用不上。3.2 每列多采样用密度换平滑当函数变化剧烈时可以在每一列内部再细分比如每列采 4 个或 8 个子采样点把每个子采样点的落点都画上。这样一列可能点亮好几个格子视觉上形成一段竖直的线段反而更接近真实曲线的陡峭部分。#define SUB 4 for (int col 0; col W; col) { for (int s 0; s SUB; s) { double t (col (double)s / SUB) / (W - 1); double mx xmin (xmax - xmin) * t; double my f(mx); if (isnan(my) || isinf(my)) continue; int row map_y(vp, my); if (row 0 row H) canvas[row][col] *; } }子采样数SUB不是越大越好。SUB太大一列会被填满曲线变成一堵墙反而看不出形状SUB太小又漏细节。我实测下来SUB取 3 到 5 之间比较平衡具体看函数振荡频率。另外注意子采样只增加纵向的落点密度横向分辨率还是受限于列数所以对高频振荡的改善有限——这是终端分辨率的硬上限认命就好。3.3 断点与渐近线别把 tan 画成一条竖线tan(x)、1/x、log(x)这类函数有渐近线或者定义域断点。逐列采样时断点两侧的函数值会一个冲到正无穷、一个冲到负无穷如果你不加处理映射出来的两个点可能一个在画布顶部、一个在底部中间用直线连起来就是一条贯穿全屏的竖线完全错误。处理办法是在采样时记录上一个有效点的行号如果当前点和上一个点的行号差距过大比如超过H/2就认为中间发生了断点不画连接直接跳过。因为我们本来就是打点而不是连线所以只要不主动去补中间的点断点自然不会连起来。真正要小心的是那些看起来连续但实际跳变的情况比如1/x在 0 附近x 从 -0.01 到 0.01函数值从 -100 跳到 100两个点分别落在画布最底和最顶中间空着视觉上是对的——两条分开的曲线。这反而是我们想要的效果。所以结论是打点式绘制天然对断点友好别画蛇添足去连线。如果你非要用-或|字符去连接相邻点那才需要专门处理断点复杂度立刻上一个台阶。我的建议是初版就用*打点简单可靠。4. 字符选择与画布渲染的讲究4.1 用哪个字符画曲线*是最常见的选择醒目、等宽、所有终端都支持。但*有个问题它在字符格子里占的面积偏大密集打点时容易糊成一片。备选方案有.轻量适合密集曲线、#更重适合强调、o圆润适合散点。我一般根据曲线密度选稀疏曲线用*或o密集曲线用.。还有一个进阶玩法根据曲线的局部斜率选不同字符。斜率接近 0水平用-斜率很大竖直用|中间用/和\。这样画出来的曲线有方向感视觉上更接近真正的线条。实现上就是在每个点计算前后两个采样点的行号差映射到四种字符之一。这个技巧在画直线和抛物线时效果特别好但代码会复杂一些属于锦上添花。/* 根据相邻点行差选择字符的简化示意 */ int prev_row -1; for (int col 0; col W; col) { /* ... 计算 row ... */ char ch *; if (prev_row 0) { int d row - prev_row; if (d 0) ch -; else if (abs(d) 2) ch |; else ch (d 0) ? \\ : /; } canvas[row][col] ch; prev_row row; }注意这里d 0用\是因为屏幕行号向下增大数学上斜率为负的线在屏幕上看起来是向右下的对应反斜杠。这种细节不实测很容易搞反我第一次写的时候就把/和\弄颠倒了画出来的抛物线像个歪嘴。4.2 坐标轴与刻度的叠加光有曲线没有坐标轴读者不知道曲线在哪个范围里。加坐标轴的方法很简单在初始化画布之后、画曲线之前先把 x 轴y0对应的行和 y 轴x0对应的列用-和|填上原点用。前提是 0 落在你的坐标范围内否则跳过。int zero_row map_y(vp, 0.0); int zero_col map_x(vp, 0.0); if (zero_row 0 zero_row H) for (int x 0; x W; x) canvas[zero_row][x] -; if (zero_col 0 zero_col W) for (int y 0; y H; y) canvas[y][zero_col] |; if (zero_row 0 zero_row H zero_col 0 zero_col W) canvas[zero_row][zero_col] ;顺序很重要先画轴再画曲线这样曲线会覆盖在轴上面交点处显示曲线字符视觉上更突出。如果反过来曲线会被轴切断看起来像断线。这个顺序问题我在早期版本里搞反过调试了半天才发现是覆盖顺序的锅。刻度标签是另一个层次的东西。在 24 行的终端里塞数字标签很容易把画面搞乱我的做法是只在轴的端点标一下范围值比如左下角标xmin、右下角标xmax中间不标。或者干脆不标让读者自己从代码里的范围参数去理解。教学演示够用了追求精确刻度就超出简单函数图像的范畴了。4.3 清屏与重绘做一个会动的图静态图跑通之后很自然会想让它动起来——比如让正弦波的相位随时间推移或者让参数a在y a*sin(x)里连续变化。终端里做动画的原理很朴素输出一帧把光标移回左上角再输出下一帧覆盖掉旧内容。关键是用 ANSI 转义序列把光标移回原点而不是用system(clear)。后者会整屏闪烁而且依赖系统命令跨平台性差。移光标的标准序列是\033[H把光标移到左上角配合\033[2J清屏可以做到干净重绘。但更省事的做法是不清屏直接覆盖——因为每帧画布大小固定新内容会完全盖住旧内容只要每行末尾补足空格就行。for (int frame 0; frame 200; frame) { /* ... 根据 frame 重新填充 canvas ... */ printf(\033[H); /* 光标回左上角 */ for (int y 0; y H; y) { fwrite(canvas[y], 1, W, stdout); putchar(\n); } fflush(stdout); /* 这里可以加一个极短的延时但标准 C 没有可移植的 sleep */ }注意fflush(stdout)不能省否则输出会缓冲动画变成一卡一卡的。至于延时标准 C 没有跨平台的sleep要么用平台相关的接口要么靠空循环凑合。我一般不在教学代码里加延时让读者自己按需处理避免引入平台依赖。注意ANSI 转义序列在绝大多数现代终端里都支持但如果输出被重定向到文件这些序列会变成乱码。所以做动画版本时最好加一个开关检测isatty(fileno(stdout))来决定是否输出转义序列。这个细节很多人忽略结果日志文件里全是^[[H这种鬼东西。5. 把函数做成参数从硬编码到可扩展5.1 函数指针让绘图逻辑与具体函数解耦初版代码里sin是写死的想换个函数就得改源码重编译。稍微进阶一点的做法是用函数指针把要画哪个函数变成运行时参数。这样一套绘图逻辑可以服务任意一元函数扩展性立刻不一样。typedef double (*Func)(double); void plot(Func f, Viewport vp, char **canvas) { for (int col 0; col vp.w; col) { double mx vp.xmin (vp.xmax - vp.xmin) * col / (vp.w - 1); double my f(mx); if (isnan(my) || isinf(my)) continue; int row map_y(vp, my); if (row 0 row vp.h) canvas[row][col] *; } }调用的时候传sin、cos、exp都行甚至传你自己写的函数。这个改动的价值在于它把画什么和怎么画分开了是软件设计里很基础但很重要的解耦思想。很多初学者写代码喜欢把所有逻辑堆在main里一旦要扩展就抓瞎这个项目正好是个练习解耦的好场景。5.2 参数化振幅、频率与相位更进一步可以把函数的参数也暴露出来。比如画y A * sin(B * x C)让A、B、C从命令行读。这样不用改代码就能演示不同参数对波形的影响很适合教学。typedef struct { double A, B, C; } SinParams; double sin_param(double x) { /* 这里需要访问全局或静态的 SinParams简化起见用静态变量 */ static SinParams p {1.0, 1.0, 0.0}; return p.A * sin(p.B * x p.C); }用静态变量是个偷懒但有效的办法缺点是线程不安全、不够优雅。更干净的做法是把参数打包进一个上下文结构体通过函数指针的签名传进去但那需要改Func的类型定义复杂度上升。对于这个规模的项目静态变量够用了我在实际演示代码里就这么干读者能看懂就行不必过度工程化。命令行解析可以用argc/argv手写也可以用getopt。手写更直观适合教学int main(int argc, char **argv) { double A 1.0, B 1.0, C 0.0; if (argc 1) A atof(argv[1]); if (argc 2) B atof(argv[2]); if (argc 3) C atof(argv[3]); /* ... 用 A B C 绘图 ... */ }atof不做错误检查输入非数字会返回 0这在演示场景里可以接受但如果你要做成工具就该换成strtod并检查endptr。这个区别值得知道但不必在初版就纠结。5.3 多函数叠加与图例有时候想在同一张图上对比两条曲线比如sin(x)和cos(x)。做法很简单用不同的字符分别画sin用*cos用o重叠的地方自然显示后画的那个。图例就在画布下方打一行* sin(x) o cos(x)。plot_with_char(sin, vp, canvas, *); plot_with_char(cos, vp, canvas, o); /* 输出画布后 */ printf(* sin(x) o cos(x)\n);多函数叠加时要注意绘制顺序和覆盖问题。如果两条曲线在某个格子重合后画的会盖掉先画的这在视觉上没问题但如果你想让重合点有特殊标记比如#就得在画之前先检测该格子是否已被占用。这个检测逻辑不难但会让代码多一层判断看需求决定要不要做。6. 实测中那些文档不会告诉你的坑6.1 浮点误差导致的缺列有一次我画一条几乎水平的直线y 0.0001 * x发现画布上每隔几列就缺一个点曲线断断续续。排查了半天问题出在round上当函数值恰好落在两个整数行的正中间时round的舍入方向受浮点误差影响相邻两列可能一个向上舍入、一个向下舍入落点就在两行之间跳来跳去视觉上就是断的。解决办法不是改round而是接受这个现象——它本质上是量化误差任何离散化都会遇到。缓解手段是让曲线粗一点比如落点之后把上下相邻的格子也标记上相当于加粗或者用子采样增加落点密度。我一般选后者因为加粗会让细曲线显得笨重。这个坑的教训是终端画图的精度受限于字符网格别指望它画出数学上完美的连续曲线。理解并接受量化误差比试图消除它更实际。6.2 制表符和全角字符会毁掉对齐画布输出时如果你不小心在某个格子里放了制表符\t或者中文全角字符整行的对齐会瞬间崩掉——制表符会跳到下一个制表位全角字符占两个格子后面的内容全部错位。这个坑我在拼接图例时踩过图例里写了中文结果和上面的画布对不齐看起来像斜的。规矩很简单画布区域只用 ASCII 半角字符图例和说明文字要么全用 ASCII要么放在画布之外单独成行别和画布混排。如果非要混排就得自己处理全角字符的宽度计算那是另一个复杂度级别的问题不值得为演示项目投入。6.3 大画布下的栈溢出风险char canvas[H][W]这种二维数组如果开得太大比如H200, W500就是 10 万字节放在栈上可能超过默认栈大小通常 1MB 到 8MB但嵌入式环境可能只有几十 KB。虽然 10 万字节一般没事但如果读者把画布开到1000×1000就是 1MB在栈紧张的平台上就危险了。稳妥的做法是用malloc在堆上分配或者用静态数组static char canvas[H][W]把它挪出栈。静态数组的缺点是全局状态、不可重入但对于单次运行的小工具完全够用。我在示例里用静态数组就是为了避免读者在小内存环境上跑崩。static char canvas[H][W]; /* 挪到静态存储区避开栈限制 */这个细节看起来不起眼但它是能跑和在别人机器上也能跑的区别。写教学代码时多考虑一步读者少踩一个坑。6.4 输出重定向后的换行问题在 Windows 上\n会被自动转成\r\n而在 Unix 上是\n。如果你把输出重定向到文件再用别的工具处理换行符的差异可能导致行数统计出错。对于终端直接显示这个差异无所谓但如果你的程序要生成可被其他程序解析的输出就得注意用二进制模式或者统一处理。这个坑比较边缘大部分演示场景遇不到但如果你打算把绘图结果存成文件做进一步分析就得留个心眼。我的建议是终端显示用文本模式存文件用二进制模式并显式写\n两边都干净。7. 从能画到画得好几个值得尝试的进阶方向7.1 用颜色区分正负值终端支持 ANSI 颜色用\033[31m红色、\033[32m绿色之类的序列可以给字符上色。一个自然的玩法是函数值为正的点用绿色为负的用红色零附近用白色。这样一眼就能看出曲线在 x 轴上下方的分布信息量比单色大得多。实现上就是在输出每个字符前根据其对应的函数值输出颜色序列输出完再重置\033[0m。注意颜色序列本身不占字符格子所以不影响对齐。但每格都输出颜色序列会让输出体积膨胀好几倍对于 80×24 的画布还能接受再大就该考虑优化了比如只在颜色变化时输出序列。7.2 用 Braille 字符提升分辨率Unicode 的盲文点字块U2800 到 U28FF每个字符内部是 2×4 的点阵相当于把一个字符格子细分成 8 个子像素。用这套字符画图等效分辨率能提升到原来的 8 倍曲线会平滑得多。代价是需要处理 UTF-8 编码而且不是所有终端字体都支持这些字符。这个技巧在终端绘图圈子里挺流行实现思路是把每个字符格子当成 2×4 的位图用位运算把子像素点亮最后映射到对应的盲文字符。代码量比纯 ASCII 版本大不少但效果提升明显。如果你已经跑通了 ASCII 版本想再往上走一步这是最值得尝试的方向。7.3 把绘图逻辑抽成可复用的小库当代码长到几百行就该考虑拆分了映射逻辑一个文件、采样逻辑一个文件、渲染逻辑一个文件用头文件串起来。这样下次想在别的项目里画个图直接链接这个小库就行不用复制粘贴。拆库的过程也是重新审视接口设计的过程Viewport结构体该暴露哪些字段、plot函数该接受哪些参数、错误怎么返回这些问题在单文件里可以糊弄拆成库就绕不过去。我个人的经验是拆库之后代码质量会明显提升因为接口边界逼着你想清楚每个模块的职责。8. 一些关于简单的再思考回过头看这个项目的标题里有个词很关键——简单。它不是指代码短而是指目标克制不追求几何精确、不追求高分辨率、不追求交互只求把一条函数曲线在终端里画出来让人一眼看懂。正是这种克制让整个项目保持在几百行以内让初学者能完整读懂也让老手能在半小时内复现。我见过太多人一上来就想做终端版 matplotlib结果陷在坐标变换、抗锯齿、字体渲染的泥潭里出不来。其实先把sin(x)用*画出来跑通、看懂、改几个参数试试收获比憋一个大而全的框架大得多。这个项目最大的价值是它逼着你把数学坐标到屏幕坐标这条链路亲手走一遍走通了后面看任何图形代码都不会发怵。最后分享一个我自己的习惯每次写完绘图代码先画y x这条 45 度直线验证映射对不对。如果直线是斜的、直的、从一角到另一角说明映射基本正确如果歪了或者断了问题一定在映射公式或者宽高比上。这条直线是最便宜的单元测试比画复杂函数更容易定位问题。等直线画对了再上正弦、抛物线、指数一步步加复杂度出错也好排查。