1 前言
进度条这个东西,几乎每天都在用:浏览器下载文件、apt install 安装软件、pip install 装包、编译器输出编译进度......它能让用户直观地看到"任务进行到哪了",而不是对着一个静止的终端干等。
这一篇不讲现成的进度条库,而是从零用 C 语言手写一个。思路很简单:先搞懂两个最基础的概念------回车和换行到底有什么区别、终端输出为什么要刷新;然后写一个只能动的"假进度条",理解原理;再给它加一个模拟下载的真实场景;最后用 ANSI 颜色控制符把它美化一下。
读完之后,你应当能够:
-
说清楚
\r和\n的区别,以及为什么进度条要用\r; -
理解行缓冲和
fflush(stdout)的作用,知道不刷新会出什么问题; -
独立写一个能按百分比推进的进度条;
-
把进度条接到一个"有总量、有速度"的任务上,让它真正有意义;
-
用 ANSI 转义序列给终端输出加颜色。
2 从倒计时小程序理解回车与换行
进度条能"原地刷新"的核心,是每次输出都回到行首重新打印。这件事在终端里靠一个字符完成:\r。但在讲进度条之前,先把 \r 和 \n 的区别、以及终端缓冲的刷新机制讲清楚,否则后面看代码会一头雾水。
2.1 \r 和 \n 到底有什么区别
很多人以为 \n 就是"回车",其实它只是换行。两个字符的含义完全不同:
| 字符 | 名称 | 作用 |
|---|---|---|
\r |
回车 | 把光标移到当前行的行首,不换行 |
\n |
换行 | 把光标移到下一行,不回到行首 |
画出来更直观:
初始状态:光标在行首
hello\rworld
执行过程:先打印 hello,光标在 hello 后面
\r 把光标拉回行首
再打印 world,覆盖掉 hello 的前 5 个字符
最终显示:world
而 \n 是往下走一行,原来的内容不会被覆盖:
hello\nworld
最终显示:
hello
world
所以进度条能原地刷新,靠的就是 \r:每次打印完把光标拉回行首,下一次打印就覆盖上一次的内容,看起来就像"同一行在动"。
顺带一提:Windows 下文本文件的换行是
\r\n(先回行首再换行),Linux/Unix 下是\n。这也是为什么跨平台传文本文件有时会出现"多一个空行"或"整个文件变成一行"的问题。
2.2 写一个倒计时小程序
用 \r 写一个最简单的倒计时,从 10 数到 0,每次覆盖上一行:
c
#include <stdio.h>
#include <unistd.h>
int main()
{
int count = 10;
while (count >= 0)
{
printf("count is:%-2d\r", count);
fflush(stdout);
sleep(1);
--count;
}
printf("\n");
return 0;
}
运行效果:

代码里几个细节值得说:
%-2d:左对齐、宽度 2。因为数字从 10 变成 9 时只占 1 位,如果不补宽度,会留下上一个数字的残留字符(10变成9而不是90),看起来像没刷干净。\r:每次打印完回到行首,下一次覆盖。fflush(stdout):这一行不能省,原因见 2.3。- 循环结束后
printf("\n"):因为最后一次输出结尾是\r,光标停在行首,不换行的话,后续输出会覆盖掉倒计时的内容。
2.3 为什么必须 fflush(stdout):行缓冲与刷新时机
把上面代码里的 fflush(stdout); 删掉,再运行一次,你会发现数字不是每秒变一次,而是等了 10 秒后一次性全部打印出来,或者只看到一个最终结果。
原因是 C 标准库的 stdout 在终端下是行缓冲的:
-
行缓冲 :输出内容先攒在缓冲区里,遇到
\n才真正写到终端; -
我们的
printf结尾是\r,不是\n,所以缓冲区一直不刷新; -
内容全攒在缓冲区里,直到程序结束或缓冲区满了才一次性输出。
fflush(stdout) 的作用就是强制把缓冲区里的内容立刻写到终端 ,不等 \n。加上它,每次 printf 之后内容立刻显示,才能看到"每秒变一次"的效果。
三种缓冲模式简单对比:
| 模式 | 触发刷新的条件 | 典型场景 |
|---|---|---|
| 全缓冲 | 缓冲区满才刷新 | 输出到文件 |
| 行缓冲 | 遇到 \n 或缓冲区满 |
输出到终端 |
| 无缓冲 | 立即刷新 | stderr |
stdout 连终端时是行缓冲,连文件时是全缓冲。进度条不写 \n,所以必须手动 fflush,否则刷新时机完全不可控。
2.4 小结
\r回行首不换行,\n换行不回行首,进度条原地刷新靠\r;stdout连终端时是行缓冲,不遇\n不刷新;- 用
\r做原地输出时,必须fflush(stdout)强制刷新,否则内容会攒到程序结束才输出; - 数字宽度要补足(
%-2d),否则短数字覆盖不干净。
3 简易进度条:原理与实现
第 2 节搞定了"原地刷新"的机制,这一节把它用起来,写一个能按百分比推进的进度条。先说明:这个版本没有真实任务,只是循环打印,目的是把进度条的形态和原理讲清楚,实际场景放到第 4 节。
3.1 进度条的形态拆解
简易版的进度条长这样:
[=========== ] 45% [/]
拆开看只有四部分:
| 部分 | 示例 | 作用 |
|---|---|---|
| 进度条主体 | [============] |
用字符填充表示已完成比例 |
| 百分比 | 45% |
精确显示进度 |
| 旋转指示器 | `[/] [-] [ | ] []` |
行尾 \r |
不显示 | 回到行首,下一帧覆盖 |
3.2 核心思路
进度条能"动",靠的是三件事:
-
固定宽度:进度条总宽度固定(比如 100 个字符位),用空格补齐,保证每一帧长度一致,覆盖时不残留;
-
按比例填充 :已完成比例
rate = curr / total * 100,填充rate个=,剩下的用空格; -
\r+fflush:每帧打印完回到行首并立刻刷新,下一帧覆盖上一帧。
3.3 代码实现
c
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#define SIZE 101
#define STYLE '='
void Process()
{
const char *lable = "|/-\\";
int len = strlen(lable);
char buffer[SIZE];
memset(buffer, '\0', SIZE);
int count = 0;
while (count < SIZE)
{
printf("[%-100s] %d%% [%c]\r", buffer, count, lable[count % len]);
fflush(stdout);
buffer[count++] = STYLE;
usleep(50000);
}
printf("\n");
}
main 里调用:
c
int main()
{
Process();
return 0;
}
运行效果:

效果是进度条从 0% 推进到 100%,每 50ms 走一格,旋转指示器 | / - \ 循环转动,到 100% 后换行。
3.4 几个关键点说明
为什么 SIZE 是 101 而不是 100?
buffer 是字符串,C 字符串结尾必须有 \0。%-100s 要求最多打印 100 个字符,加上结尾的 \0 正好 101 字节。如果 SIZE 写成 100,填满 100 个 = 后就没有位置放 \0,printf 会越界读取,输出可能乱码或崩溃。
为什么用 memset 初始化?
buffer 里除了已填充的 =,其余位置必须是 \0(或者至少是空格),否则 %-100s 打印时会读到未初始化的垃圾数据。memset(buffer, '\0', SIZE) 把整个数组清零,保证每一帧都是干净的。
为什么每帧打印完才填充 buffer[count++]?
顺序是"先打印当前帧,再填充下一格"。第一帧 buffer 全空,打印 [空格...] 0%;然后填一格 =,第二帧打印 [=空格...] 1%,依此类推。如果反过来先填充再打印,第一帧就会显示 1%,和 count 对不上。
旋转指示器怎么转的?
lable = "|/-\\" 是四个字符的字符串(\\ 是转义后的反斜杠),count % len 在 0~3 之间循环,每次取一个字符,看起来就像在转。注意字符串里写 \\ 而不是 \,否则会被当成转义符。
usleep(50000) 是什么?
微秒级睡眠,50000 微秒 = 50ms。每帧停 50ms,100 格走完大约 5 秒。usleep 在 <unistd.h> 里,和 sleep(秒级)对应。
3.5 这个版本的局限
这个进度条能跑、能看,但它没有真实任务:循环次数写死 101 次,进度和任何实际工作无关,纯粹是"为了动而动"。
真实场景里,进度条应该由任务的完成度驱动:下载了多少字节、编译了多少文件、安装了多少包。下一节就给它接一个模拟下载的任务,让进度条真正"有意义"。
4 模拟网络下载:给进度条一个真实场景
第 3 节的进度条没有真实任务,循环次数写死,纯粹为了演示"怎么动"。这一节给它接一个模拟下载的任务,让进度条的推进由已下载量 / 总量驱动,这样它才真正对应一个"有进度"的工作。
4.1 为什么要模拟下载
真实下载的进度条,进度由两个量决定:
- 总量(total):文件一共多大;
- 已下载量(curr):当前已经下载了多少。
进度 = curr / total * 100%。下载过程中 curr 不断增长,进度条就不断推进;curr 追上 total 时,进度到 100%,下载结束。
模拟下载就是把这个过程用代码"演"出来:不需要真的联网,用 usleep 模拟下载耗时,用随机速度模拟网络波动,就能得到一个行为和真实下载高度相似的场景。
4.2 回调:把"进度更新"和"下载逻辑"分开
这里引入一个设计上的小技巧:回调函数。
DownLoad 只负责模拟下载,不关心进度条长什么样;进度条只负责显示,不关心数据从哪来。两者用一个函数指针连起来------DownLoad 每下载一点,就调用一次回调,把进度传给进度条。这样以后换成真实下载代码,进度条完全不用改。
回调的类型定义成:
c
// 回调函数类型:接收"总量"和"当前已下载量",由进度条函数实现
typedef void (*callback_t)(double total, double curr);
DownLoad 接收这个回调,每下载一点就调用它:
c
// 模拟下载:total 是总大小,cb 是进度更新回调
void DownLoad(double total, callback_t cb)
{
srand(time(NULL)); // 初始化随机数种子,让每次速度不同
double curr = 0.0; // 当前已下载量
while (1)
{
// 已下载量达到或超过总量,说明下载完成
if (curr >= total)
{
curr = total; // 钳到总量,避免显示超过 100%
cb(total, curr); // 最后一次回调,把进度刷到 100%
break;
}
cb(total, curr); // 每下载一点就更新一次进度条
curr += SpeedFloat(speed, 10.0); // 模拟本次下载的数据量(速度随机波动)
usleep(30000); // 模拟下载耗时,30ms
}
}
进度条的刷新逻辑封装在 FlushProcess 里。它接收 total 和 curr,算出百分比并打印进度条。因为它正好符合 callback_t 的签名,所以可以直接作为回调传给 DownLoad:
c
// 进度条回调:根据 total 和 curr 计算进度并刷新显示
void FlushProcess(double total, double curr)
{
if (curr > total)
curr = total; // 防御性处理,防止超过 100%
double rate = curr / total * 100; // 计算百分比
int cnt = (int)rate; // 需要填充的 '=' 个数
char buffer[SIZE];
memset(buffer, '\0', SIZE); // 先清空,保证字符串干净
int i = 0;
for (; i < cnt; i++)
buffer[i] = STYLE; // 按进度填充 '='
static const char *lable = "|/-\\"; // 旋转指示器字符集
static int index = 0; // 静态变量,跨调用保持旋转位置
// \r 回到行首覆盖上一帧;%-100s 左对齐补空格,保证每帧等宽
printf("[%-100s] [%.1lf%%] [%c]\r", buffer, rate, lable[index++]);
index %= strlen(lable); // 循环取下一个旋转字符
fflush(stdout); // 立刻刷新,否则进度条不会动
if (curr >= total)
printf("\n"); // 下载完成,换行结束这一条进度条
}
这样 DownLoad 换成真实下载代码时,进度条完全不用改------这就是回调的价值。
4.3 模拟下载速度
真实网络的速度是波动的,这里用 SpeedFloat 模拟:
c
double SpeedFloat(double start, double range)
{
int int_range = (int)range;
return start + rand() % int_range + (range - int_range);
}
它返回一个 start 到 start + range 之间的数,每次调用都不同,模拟"这次下载快一点、下次慢一点"。usleep(30000) 让每次下载间隔 30ms,看起来有持续进行的感觉。
这里的随机数不是严格均匀分布,但作为演示足够。重点是速度是变化的,进度条不会匀速推进,更像真实网络。
4.4 主函数:连续模拟多个文件下载
c
int main()
{
printf("download: 50MB\n");
DownLoad(50.0, FlushProcess);
printf("download: 200MB\n");
DownLoad(200.0, FlushProcess);
printf("download: 512MB\n");
DownLoad(512.0, FlushProcess);
printf("download: 1024MB\n");
DownLoad(1024.0, FlushProcess);
return 0;
}
依次模拟下载 50MB、200MB、512MB、1024MB 四个文件。因为总量不同、速度随机,每个文件下载耗时不一样,进度条推进的快慢也不一样------这和真实场景里"小文件瞬间完成、大文件慢慢走"的感觉是一致的。
运行效果:

5 用颜色控制符美化进度条
第 4 节的进度条已经能用了,但输出是纯白字符,看着比较朴素。这一节用终端支持的 ANSI 转义序列给它加颜色:进度部分用绿色块、未完成部分用灰色槽、百分比用黄色,视觉上更接近现代软件里的进度条。
5.1 ANSI 转义序列基础
终端里的颜色不是靠"颜色函数"实现的,而是靠转义序列 ------一种以 \033[(ESC 字符)开头、以 m 结尾的特殊字符串。终端读到它时不会显示出来,而是改变后续文本的显示样式。
基本格式:
\033[参数m
常用参数:
| 参数 | 含义 |
|---|---|
0 |
重置所有样式 |
1 |
高亮/加粗 |
30~37 |
前景色(黑、红、绿、黄、蓝、紫、青、白) |
40~47 |
背景色(同上八种) |
100 |
暗灰背景(高亮背景色) |
比如 \033[33m 是黄色前景,\033[42m 是绿色背景,\033[0m 是重置。
直接写 \033[33m 可读性差,用 #define 封装成宏:
c
#define COLOR_YELLOW "\033[33m" // 黄色前景
#define BG_GREEN "\033[42m" // 绿色背景
#define BG_GRAY "\033[100m" // 暗灰背景,做未完成槽
#define RESET "\033[0m" // 重置所有样式
后面直接用 BG_GREEN " " 就表示"一个带绿色背景的空格",比每次手写转义序列清楚得多。
5.2 颜色版进度条的改动
和字符版相比,颜色版做了三处改动:
(1)用背景色块代替 = 字符
字符版靠 = 的个数表示进度,颜色版靠带背景色的空格表示进度:
c
if (i < cnt)
printf(BG_GREEN " "); // 绿色实心块,表示已完成
else
printf(BG_GRAY " "); // 灰色槽,表示未完成
BG_GREEN " " 就是一个绿色背景的空格,视觉上是一块绿色方块。原理和字符版完全一样,只是"填充物"从 = 换成了色块。
(2)不再拼 buffer,逐块打印
字符版先把 = 填进 buffer,再一次性 printf。颜色版每个色块都要带独立的转义序列,拼进一个 buffer 会很复杂,所以改成循环里逐个 printf。代价是输出次数变多,但进度条只有 100 个色块,性能可以忽略。
(3)先清行再重画
c
printf("\r\033[K");
\r 回到行首,\033[K 清除从光标到行尾的内容。字符版靠 %-100s 固定宽度避免残影,颜色版用清行更保险------即使这一帧比上一帧短,也不会留下上一帧的残影。
5.3 代码实现
c
void FlushProcess(double total, double curr)
{
if (curr > total)
curr = total; // 防御性处理,防止超过 100%
double rate = curr / total * 100; // 计算百分比
int cnt = (int)rate; // 需要填充的绿色块个数
static const char *lable = "|/-\\"; // 旋转指示器字符集
static int index = 0; // 静态变量,跨调用保持旋转位置
// \r 回到行首,\033[K 清除整行,防止上一帧残留
printf("\r\033[K");
int i = 0;
for (; i < 100; i++)
{
if (i < cnt)
printf(BG_GREEN " "); // 绿色实心块
else
printf(BG_GRAY " "); // 灰色槽
}
// 重置颜色后打一个空格,再用黄色显示百分比和旋转指示器
printf(RESET " " COLOR_YELLOW "%.1lf%% [%c]" RESET, rate, lable[index++]);
index %= strlen(lable); // 循环取下一个旋转字符
fflush(stdout); // 立刻刷新,否则进度条不会动
if (curr >= total)
printf("\n"); // 下载完成,换行结束这一条进度条
}
和字符版对比一下,核心结构没变:还是"算百分比 → 填充 → 打印百分比和旋转指示器 → fflush → 完成时换行"。变的只是"填充物"从 = 变成了色块,以及多了一行清行。
运行效果:

5.5 颜色宏速查表
以后想换配色,改这几个宏就行:
| 宏 | 值 | 效果 |
|---|---|---|
COLOR_RED |
\033[31m |
红色前景 |
COLOR_GREEN |
\033[32m |
绿色前景 |
COLOR_YELLOW |
\033[33m |
黄色前景 |
COLOR_BLUE |
\033[34m |
蓝色前景 |
BG_RED |
\033[41m |
红色背景 |
BG_GREEN |
\033[42m |
绿色背景 |
BG_YELLOW |
\033[43m |
黄色背景 |
BG_GRAY |
\033[100m |
暗灰背景 |
RESET |
\033[0m |
重置所有样式 |
注意 :每次用完颜色记得 RESET,否则后续输出会一直带着颜色。代码里百分比打印完跟一个 RESET,就是让颜色只作用于进度条这一行。
希望这篇文章对你有帮助。
本文代码环境:Linux(CentOS 7)/ gcc
完整代码仓库:见评论区
如果本文对你有帮助,欢迎点赞、收藏、关注------你的支持是我持续更新的最大动力!如有疑问,欢迎在评论区留言讨论~