一个健壮的程序,不仅要能算得对,还要能应对时间与外部世界的干预:知道现在几点、计算一段代码跑了多久;当用户按下 Ctrl+C 时优雅退出而不是直接暴毙;在极端错误时从嵌套的函数深处直接跳回安全地带。
今天这三样本领,对应的就是 C 语言中的时间处理、信号处理和非局部跳转。它们是程序控制领域的“软技能”——不直接产出算法结果,却决定了程序的鲁棒性和用户体验。
一、时间的两种面貌:日历时间与程序计时
C 语言处理时间分为两类需求:
- 日历时间:现在几点?今天的日期?格式化输出时间戳。
- 程序计时:这段代码跑了多久?精确到微秒或纳秒。
1. 日历时间:time() 与 ctime()
最基础的时间函数是 time(),返回自 1970-01-01 00:00:00 UTC 至今的秒数(称为 Unix 时间戳)。定义在 <time.h>。
#include <stdio.h>
#include <time.h>
int main(void) {
time_t now = time(NULL); // 获取当前时间戳
if (now == (time_t)-1) {
perror("time 失败");
return 1;
}
printf("时间戳: %ld\n", (long)now);
// 转换为可读字符串
char *str = ctime(&now);
printf("当前时间: %s", str); // 自带换行符
return 0;
}
输出类似:
时间戳: 1753152000
当前时间: Mon Jul 21 14:30:00 2025
ctime 直接返回一个静态分配的字符串(不需要手动 free,但下次调用会覆盖)。time_t 通常是 long 的别名。
2. 分解时间:localtime() 与 strftime()
ctime 格式固定,不够灵活。需要自定义格式时,先用 localtime 把 time_t 分解成 struct tm,再用 strftime 格式化。
#include <stdio.h>
#include <time.h>
int main(void) {
time_t now = time(NULL);
struct tm *local = localtime(&now);
printf("年: %d\n", local->tm_year + 1900); // 从 1900 起算
printf("月: %d\n", local->tm_mon + 1); // 0~11
printf("日: %d\n", local->tm_mday);
printf("星期: %d (0=周日)\n", local->tm_wday);
printf("时: %d\n", local->tm_hour);
printf("分: %d\n", local->tm_min);
printf("秒: %d\n", local->tm_sec);
// 自定义格式化
char buf[100];
strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", local);
printf("格式化: %s\n", buf); // 2025-07-21 14:30:00
return 0;
}
strftime 的格式符非常丰富,%Y 四位年份,%m 两位月份,%d 两位日期,%H 24小时制小时,%M 分,%S 秒。更多格式符请查手册。
注意:
localtime返回指向静态内存的指针,非线程安全。在多线程程序中使用localtime_r(POSIX)替代。
3. 程序计时:clock() 与高精度替代
clock() 我们在第三十四篇用过,它测量的是 CPU 时间,精度通常为毫秒级。适合对比算法效率。
如果需要更高精度(微秒甚至纳秒),POSIX 提供 gettimeofday 或 clock_gettime:
#include <stdio.h>
#include <time.h> // clock_gettime
int main(void) {
struct timespec start, end;
clock_gettime(CLOCK_MONOTONIC, &start);
// 模拟耗时操作
for (volatile long i = 0; i < 100000000; i++) { }
clock_gettime(CLOCK_MONOTONIC, &end);
double elapsed = (end.tv_sec - start.tv_sec)
+ (end.tv_nsec - start.tv_nsec) / 1e9;
printf("耗时: %.6f 秒\n", elapsed);
return 0;
}
CLOCK_MONOTONIC 是单调时钟,不受系统时间调整影响,适合测量时间间隔。CLOCK_REALTIME 是系统实时时钟,可能被用户或 NTP 调整。
二、信号处理:与外部世界对话
信号是操作系统向进程发送的异步通知。比如你按 Ctrl+C,内核向当前前台进程发送 SIGINT 信号;程序访问了非法内存,内核发送 SIGSEGV。程序可以注册信号处理函数来响应这些信号,而不是让默认行为(通常是终止程序)直接发生。
1. 常见信号
| 信号 | 值 | 默认行为 | 触发场景 |
|---|---|---|---|
SIGINT | 2 | 终止 | 键盘 Ctrl+C |
SIGTERM | 15 | 终止 | kill 命令默认发送 |
SIGKILL | 9 | 终止(无法捕获或忽略) | kill -9 |
SIGSTOP | 19 | 暂停(无法捕获或忽略) | Ctrl+Z |
SIGSEGV | 11 | 终止并 core dump | 非法内存访问 |
SIGABRT | 6 | 终止并 core dump | abort() 调用 |
SIGALRM | 14 | 终止 | alarm() 定时器到期 |
SIGUSR1 | 10 | 终止 | 用户自定义 |
SIGUSR2 | 12 | 终止 | 用户自定义 |
信号定义在 <signal.h>。
2. signal():注册信号处理函数
#include <signal.h>
typedef void (*sighandler_t)(int);
sighandler_t signal(int signum, sighandler_t handler);
signum:信号编号。handler:信号处理函数(接收信号编号),或SIG_IGN(忽略),或SIG_DFL(恢复默认)。- 返回值:之前的处理函数指针,出错返回
SIG_ERR。
示例:优雅地响应 Ctrl+C
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
// 全局标志(注意:必须是 volatile sig_atomic_t 类型!)
volatile sig_atomic_t keep_running = 1;
void handle_sigint(int sig) {
const char msg[] = "\n收到 SIGINT,正在优雅退出...\n";
write(STDERR_FILENO, msg, sizeof(msg) - 1);
keep_running = 0;
}
int main(void) {
// 注册 SIGINT 的处理函数
if (signal(SIGINT, handle_sigint) == SIG_ERR) {
perror("signal 注册失败");
return 1;
}
printf("程序运行中,按 Ctrl+C 退出\n");
while (keep_running) {
printf("工作中...\n");
sleep(1);
}
printf("程序已退出。\n");
return 0;
}
运行后按 Ctrl+C,程序不会直接终止,而是打印提示并将 keep_running 设为 0,循环自然结束。
关键细节:
- 信号处理函数必须使用
volatile sig_atomic_t类型的全局变量与主程序通信。这个类型保证读写是原子性的(不会被信号中断打断一半),volatile防止编译器缓存其值。 - 信号处理函数中尽量只做简单操作(设置标志)。不要在信号处理函数中调用
printf(它不是“异步信号安全”的),虽然很多实现中它可以工作,但标准不保证。若要安全输出,使用write()。
3. 实用信号场景
定时器:用 alarm(seconds) 或 setitimer 设置定时,到期发送 SIGALRM。可用于实现超时机制。
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
volatile sig_atomic_t timeout = 0;
void handle_alarm(int sig) {
timeout = 1;
}
int main(void) {
signal(SIGALRM, handle_alarm);
printf("5 秒倒计时...\n");
alarm(5); // 5 秒后发送 SIGALRM
while (!timeout) {
// 模拟工作
usleep(100000);
}
printf("时间到!\n");
return 0;
}
忽略信号:
signal(SIGPIPE, SIG_IGN); // 忽略管道破裂信号
服务器程序中经常忽略 SIGPIPE,因为向关闭的 socket 写数据会触发该信号,而程序想自己处理写入错误而不是被信号杀死。
三、setjmp 与 longjmp:跨越函数栈的跳转
C 语言没有异常处理机制(不像 C++ 的 try/catch)。但有时你需要在深层函数中遇到不可恢复的错误时,直接跳回最外层,而不是一层层 return 错误码。setjmp/longjmp 就是干这个的。
1. 基本原理
setjmp 在当前位置保存“执行环境”(寄存器、栈指针、程序计数器等),返回 0。之后任何地方调用 longjmp,程序状态会回到 setjmp 那一刻,而且 setjmp 会返回非 0 值(longjmp 的第二个参数),从而区分“首次执行”还是“跳转回来”。
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
void inner(void) {
printf("inner: 发现错误,准备跳回!\n");
longjmp(env, 1); // 跳回 setjmp,并使它返回 1
printf("这行不会执行\n");
}
int main(void) {
if (setjmp(env) == 0) {
// 首次执行
printf("首次进入,调用 inner...\n");
inner();
printf("这行也不会执行\n");
} else {
// longjmp 跳回这里
printf("从 inner 跳回,执行清理工作\n");
}
return 0;
}
输出:
首次进入,调用 inner...
inner: 发现错误,准备跳回!
从 inner 跳回,执行清理工作
2. 典型用途:深层错误恢复
想象一个解析器,在递归下降的深处遇到语法错误。一种处理方式是每层函数都返回错误码,逐层向上传递。用 setjmp/longjmp 则可以“一步到位”:
#include <stdio.h>
#include <setjmp.h>
#include <stdlib.h>
jmp_buf parser_env;
void parse_expression(void) {
// 模拟深层错误
printf("解析表达式... 错误!\n");
longjmp(parser_env, 1);
}
void parse_statement(void) {
parse_expression();
}
void parse_program(void) {
parse_statement();
}
int main(void) {
if (setjmp(parser_env) == 0) {
parse_program();
} else {
printf("解析失败,已恢复。请检查输入。\n");
}
return 0;
}
3. 陷阱与限制
setjmp/longjmp 功能强大,但有几条必须遵守的规则:
限制 1:不能跳回已结束的函数
void bad(void) {
jmp_buf env;
if (setjmp(env) == 0) {
return; // bad 返回后,env 所在的栈帧已失效
}
}
// 其他地方 longjmp(env, 1) → 未定义行为!
jmp_buf 必须定义在调用 setjmp 后仍存活的函数中(通常是 main 或顶层调用者)。
限制 2:局部变量的值不确定
longjmp 跳回后,setjmp 所在函数中的非 volatile 局部变量,如果在 setjmp 和 longjmp 之间被修改过,其值是不确定的(C 标准这么说)。如果需要在跳回后读取这些变量,用 volatile 修饰。
#include <stdio.h>
#include <setjmp.h>
jmp_buf env;
int main(void) {
int x = 1;
volatile int y = 1; // volatile 保证值正确
if (setjmp(env) == 0) {
x = 100;
y = 100;
longjmp(env, 1);
} else {
printf("x = %d (不确定), y = %d (确定是 100)\n", x, y);
}
return 0;
}
限制 3:资源泄漏
longjmp 会跳过局部变量的析构……等等,C 没有析构函数。但它会跳过 free() 调用!如果中间有 malloc 但没有在跳回后释放,就会内存泄漏。
void *buffer = malloc(1024);
// ... 处理 ...
longjmp(env, 1); // 跳过 free(buffer)!
free(buffer);
所以在 setjmp 的“恢复分支”中,必须能清理之前分配的资源(通常通过全局指针记录)。
四、实战:带超时的网络连接模拟
把时间、信号、跳转结合起来,模拟一个带超时的操作:
#include <stdio.h>
#include <signal.h>
#include <setjmp.h>
#include <unistd.h>
jmp_buf timeout_env;
void handle_alarm(int sig) {
longjmp(timeout_env, 1); // 超时直接跳回
}
int main(void) {
signal(SIGALRM, handle_alarm);
printf("尝试连接(3 秒超时)...\n");
if (setjmp(timeout_env) == 0) {
alarm(3); // 设置 3 秒超时
// 模拟耗时操作(如果超过 3 秒,alarm 触发跳转)
sleep(5); // 模拟超时情况
alarm(0); // 取消闹钟
printf("连接成功!\n");
} else {
printf("操作超时!\n");
}
return 0;
}
运行结果:sleep(5) 未完成就被 SIGALRM 中断,longjmp 跳回打印“操作超时”。
如果 sleep(2)(小于 3 秒),则会先取消闹钟,输出“连接成功”。
Windows环境下:
#include <stdio.h>
#include <windows.h>
// 模拟耗时操作的线程函数
DWORD WINAPI connect_thread(LPVOID arg) {
Sleep(5000); // 模拟 5 秒连接耗时
return 42; // 返回结果
}
int main(void) {
printf("尝试连接(3 秒超时)...\n");
HANDLE hThread = CreateThread(NULL, 0, connect_thread, NULL, 0, NULL);
if (hThread == NULL) {
fprintf(stderr, "创建线程失败\n");
return 1;
}
// 等待线程完成,最多等 3 秒
DWORD result = WaitForSingleObject(hThread, 3000);
if (result == WAIT_TIMEOUT) {
printf("操作超时!\n");
TerminateThread(hThread, 0); // 强制终止
} else if (result == WAIT_OBJECT_0) {
DWORD exitCode;
GetExitCodeThread(hThread, &exitCode);
printf("连接成功!线程返回值: %lu\n", exitCode);
} else {
printf("等待失败\n");
}
CloseHandle(hThread);
return 0;
}
五、小结
今天你学会了三项“程序控制”的高级技能:
- 时间处理:
time()获取时间戳,localtime+strftime灵活格式化;clock_gettime做高精度计时。 - 信号处理:
signal捕获SIGINT、SIGALRM等,用volatile sig_atomic_t标志安全退出,alarm做简单超时。 - 非局部跳转:
setjmp/longjmp在深层错误时一步跳回,但注意变量volatile和资源清理问题。
这些技术让程序从“按部就班执行”升级为“能感知时间、能响应外部信号、能从错误中恢复”的健壮系统。信号处理在守护进程和服务器中至关重要;setjmp/longjmp 是 C 语言实现简易异常处理或协程的核心机制。
至此,系统编程的各个模块——文件 I/O、库制作、多线程、信号、时间——已经覆盖完毕。下一篇,我们将迎来一个综合实战:打造一个迷你 HTTP 服务器。把前面学到的 socket 原理(通过系统调用铺垫)、多线程、文件 I/O、字符串处理、信号处理全部串联起来,写出一个真正能用的网络服务。这是对整个 C 语言知识体系的一次大阅兵。
课后小练习
- 写一个程序,获取当前时间并以“YYYY-MM-DD HH:MM:SS”格式输出。再计算距离 2030 年 1 月 1 日还有多少秒,并输出倒计时。
- 实现一个“安全重启”程序:主循环执行某些工作,捕获
SIGHUP信号(kill -1发送)后重新加载配置(打印一条模拟消息即可),捕获SIGTERM后优雅退出。 - 用
setjmp/longjmp实现一个简易的“重试”机制:定义一个宏TRY和CATCH,让用户可以在操作失败时跳回恢复点。 - (小挑战)结合
SIGALRM和setjmp/longjmp,实现一个通用的timeout_execute函数:传入一个函数指针和超时秒数,在该时间内执行函数;若超时则返回错误码。
我们下期见!
💡获取本系列示例代码请访问 GitCode 仓库。
4077



被折叠的 条评论
为什么被折叠?



