
简介这份C语言实现的农历万年历最新版支持1901至2300年间的公历与农历信息查询能够以整齐美观的日历格式打印输出。公历信息包含日期、星期与节日农历信息涵盖二十四节气精确时间点、闰月、大小月、三伏、九九、干支、年号、农历节假日及属相等还支持公历农历日期双向转换并可直接查询生辰八字功能全面。对于C语言学习者、日历工具开发者及传统历法爱好者是理解农历推算和日期转换算法的实用参考。资源包共11个文件体积约169KB包含C源程序、配套头文件、配置文件、可执行程序、构建脚本及静态库等既有可直接运行的版本也有完整源代码可深入研读覆盖从源码编译到运行的完整环节目前已有4386人学习下载。除核心历法计算外资源支持自定义公历农历节日与纪念日可灵活扩展个性化日历功能代码层次较为清晰适合深入研究农历算法或集成到实际项目中。 每年到了年末总有人拿“农历腊月三十去哪儿了”来问我。作为一个常年跟C语言打交道的嵌入式开发者我干脆把自己维护了好几年的农历万年历项目重新整理了一遍。这次的最新版做到了真正的农历公历双向转换、闰月处理、干支生肖输出还顺手把节气近似计算也加了进去。网上讲农历算法的帖子不少但大多数只贴片段代码要么就是没有讲清楚数据表怎么来的、闰月怎么处理。这篇文章把我这版完整实现的核心思路、数据结构和踩坑过程全部拆开讲无论你是刚学完C语言想做点像样项目的学生还是要做日历显示、智能时钟、嵌入式设备的开发者都能直接拿去用。1. 项目概述与算法选型1.1 为什么农历换算天生难缠很多人以为农历就是看月亮每个月28天一圈其实完全不是这么回事。农历是阴阳合历既要照顾月相变化又要跟地球绕太阳的回归年对齐。一个朔望月的平均长度是29.53059天所以农历月只能做大小月大月30天、小月29天每个月到底是29还是30天要看新月出现的那一瞬间在哪个时区这背后是天体力学计算。更麻烦的是闰月。12个朔望月大约是354.367天比回归年的365.2422天少了差不多11天。如果不处理过几年农历新年就会跑到夏天去。老祖宗的解决办法是十九年七闰每隔两三年加一个闰月让农历年变成383到385天的闰年。闰月在哪个月又涉及“无中气月置闰”的规则中气是二十四节气里排双数的那些节气一个农历月中没出现中气这个月就被设置为闰月。这规则一说出来你就明白想用纯数学公式在现场推算出某一天是农历几月几号几乎是不可能的涉及太多天文常数和摄动计算。1.2 两条技术路线我选了查表法具体实现上有两条路纯天文计算和查表法。纯天文计算要拿太阳和月亮的黄经、黄纬做高精度轨道模型其中月球的运动要处理几百项摄动修正代码量轻松上千行而且每算一个日期都要做浮点三角函数运算在低端单片机上跑起来很吃力。另一个问题是验证成本高算错了你都不知道是哪个环节出了问题。查表法就直白多了1950年到2100年甚至1900年到2100年的农历信息早就被历法机构逐年推算好并公开发表过。我把这些结果压缩成一张很小的表放进程序运行时只做整数加减和位运算不管你是x86电脑还是Cortex-M0单片机都能瞬间完成转换。我做这个项目优先选查表法核心原因是稳定、可验证、跨平台表现一致。这张表一旦确认无误代码的正确性就只取决于你的逻辑不会因为换一台机器或者换一套浮点库而出现日期漂移。2. 核心数据结构设计2.1 一年一份农历信息全压进一个int这是整个项目最核心的部分也是网上代码里最容易让人看懵的地方。每一年的农历信息包含月份大小、闰月月份、闰月大小我用一个unsigned int变量全部存下来。常见的编码规则是这样的以某些公开资料里1900年数据0x04BD8为例拆成二进制是0x04BD8 0000 0100 1011 1101 1000从左往右解释低位第0到第3位也就是0x08表示闰月月份值为8表示闰八月0表示没有闰月第4位0x10这一位表示闰月是大月还是小月1是大月30天0是小月29天第5位到第16位一共12位分别表示从正月到腊月的12个月是大月还是小月1为大月0为小月这里有点绕因为不同源码的位定义不一样。我这版统一采用上面这个规则并且用宏把它包装起来避免到处写魔法数#define LEAP_MONTH(x) ((x) 0x0F) // 低位4位闰月月份 #define LEAP_MONTH_BIG(x) (((x) 4) 0x01) // 闰月大小 #define MONTH_BITS(x) ((x) 5) // 高12位是12个月的大小然后判断第n个月是不是大月int is_month_big(unsigned int yearInfo, int month) { return (MONTH_BITS(yearInfo) (month - 1)) 0x01; }实际年份数据表就去公开数据源核对比如1900年到2100年的农历数据整理成一个只读数组static const unsigned int lunarInfo[] { 0x04BD8, 0x04AE0, 0x0A570, 0x054D5, 0x0D260, // ... 逐年的数据 };这一行数据就能算出一整年的农历结构。当年有没有闰月、闰哪个月、每个月几天全部信息都在这16位里。这也是C语言位运算最有价值的实战场景比单纯刷几道“将一个整数中的某一位取出来”的题目要直观得多。2.2 基准日期与时间转换函数查表法需要定一个基准日。我选择1900年1月31日这是农历1900年正月初一也是网上流传最广、验证用例最多的起点。整个转换的核心是先算出目标公历日期距离基准日过了多少天再拿这个天数去农历数据表里逐年、逐月扣除扣到哪一年哪一月就是答案。所以第一步是写一个把公历日期转成“相对基准日的天数”的函数。这里要小心闰年判断我直接用了标准的格里高利历规则int is_leap_year(int year) { return (year % 4 0 year % 100 ! 0) || (year % 400 0); } int days_in_month(int year, int month) { static const int days[] {31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31}; if (month 2 is_leap_year(year)) return 29; return days[month - 1]; } int date_to_days(int year, int month, int day) { int days 0; int y, m; for (y 1900; y year; y) days is_leap_year(y) ? 366 : 365; for (m 1; m month; m) days days_in_month(year, m); days day - 1; return days; }这里date_to_days(1900, 1, 31)应该返回0因为它的循环从1900年开始当年不参与年循环月循环从1月开始加到1月最后加31-1得到30天不对当输入是1900年1月31日时第一个循环year1900不执行第二个循环从1月到1月不执行最后加31-130。所以基准日应该返回0实际应该返回0等一下代码的语义是1月1日返回01月31日返回30。这样基准日1900年1月31日相当于第30天。如果我们希望基准日的offset为0就应调整days day - 1那基准日返回30。这样后面计算目标-基准就会差30。正确做法是把基准日定义为第0天或者将days定义成从1900年1月0日算起的天数。例如把day - 1改成day然后基准日返回31也算不均匀。实际代码中我们只需要计算“两个公历日期之间的天数差”用date_to_days(target) - date_to_days(base)即可只要基准日的绝对天数一致就不会出问题。但是后面lunar_to_solar从基准日加天数时需要统一约定。我最终采用date_to_days返回的是从1900年1月0日起算的天数即days day而不再是day - 1这样1900年1月1日就是11月31日就是31。这样描述更自然跟C标准库的tm_yday也接近。修正一下逻辑int date_to_days(int year, int month, int day) { int days 0; int y, m; for (y 1900; y year; y) days is_leap_year(y) ? 366 : 365; for (m 1; m month; m) days days_in_month(year, m); days day; return days; }这样date_to_days(1900,1,31)返回31基准日差就是31。后续做加减的时候都基于这个约定保证逻辑一致。我写了个days_to_date函数反向转回公历日期用于农历转公历的最终输出。2.3 公历转农历的实现有了“天数差”这个中间量后公历转农历就变成一道减法题用date_to_days(target) - date_to_days(1900, 1, 31)算出偏移天数从1900年开始遍历每一年用lunarYearDays(year)算出这一农历年总共多少天从偏移量里减掉直到不够减此时定位到农历年在这一年里先判断有没有闰月按“正月到腊月、闰月插在对应位置”的顺序遍历每个月逐月扣除该月的天数直到定位到农历月剩下的天数加1就是农历日计算某个农历年的总天数需要按数据表把12个月加闰月的天数汇总int lunar_month_days(unsigned int yearInfo, int month, int leap) { if (leap) return LEAP_MONTH_BIG(yearInfo) ? 30 : 29; return is_month_big(yearInfo, month) ? 30 : 29; } int lunar_year_days(int year) { int sum 0; unsigned int info lunarInfo[year - 1900]; int leap LEAP_MONTH(info); int i; for (i 1; i 12; i) sum lunar_month_days(info, i, 0); if (leap) sum LEAP_MONTH_BIG(info) ? 30 : 29; return sum; }这里的细节是is_month_big里用了MONTH_BITS(yearInfo) (month - 1)12位bit分别代表正月到腊月。这样遍历的时候只要按月份取位就行不涉及复杂的循环移位。主转换函数的关键循环我贴出来typedef struct { int year; // 农历年 int month; // 农历月闰月用负值或单独标记 int day; // 农历日 int isLeap; // 是否闰月 } LunarDate; LunarDate solar_to_lunar(int year, int month, int day) { int offset date_to_days(year, month, day) - date_to_days(1900, 1, 31); LunarDate result {0}; int i, yearDays; // 定位农历年 for (i 1900; offset 0 i 2100; i) { yearDays lunar_year_days(i); if (offset yearDays) break; offset - yearDays; } result.year i; // 定位农历月 unsigned int info lunarInfo[i - 1900]; int leapMonth LEAP_MONTH(info); int monthPassed 0; for (int m 1; m 12; m) { monthPassed; yearDays lunar_month_days(info, m, 0); if (offset yearDays) break; offset - yearDays; if (m leapMonth) { yearDays LEAP_MONTH_BIG(info) ? 30 : 29; if (offset yearDays) { result.month m; result.isLeap 1; result.day offset 1; return result; } offset - yearDays; } } result.month monthPassed; result.isLeap 0; result.day offset 1; return result; }这里monthPassed看起来有个问题如果闰月在第m月之后那么正常顺序是1到m闰mm1到12。按上面的循环遇到mleapMonth时先判断闰月如果没有落入闰月就减去闰月天数然后继续m1。这样monthPassed在循环里每次加1固然记录的是当前判断到第几个正常月吗其实用monthPassed记录月序号不一定准因为如果闰月被跳过月份序号会错位比如正常月1月、闰1月、正常2月monthPassed会在第一次循环时变为1第二次m1又加1变成2第三次循环m2时又加1变成3输出month3但它实际是农历2月。所以直接用一个循环变量按正常月的顺序走碰到闰月做一个分支就行for (int m 1; m 12; m) { yearDays lunar_month_days(info, m, 0); if (offset yearDays) { result.month m; result.isLeap 0; result.day offset 1; return result; } offset - yearDays; if (m leapMonth) { yearDays LEAP_MONTH_BIG(info) ? 30 : 29; if (offset yearDays) { result.month m; result.isLeap 1; result.day offset 1; return result; } offset - yearDays; } }这样m表示正常月序号闰月紧跟其后逻辑就顺了。所以在写这种处理顺序相关的循环时不能用“统计计数”代替“状态位置”这个教训我在调试闰年数据时吃了不少苦头。3. 农历转公历与万年历展示3.1 农历转公历按天累加回去农历转公历是反过来把输入的农历年月日累加成偏移天数然后加到基准日上。这里最容易错的是闰月参数输入时用户说“闰四月十五”和“四月十五”是两个完全不同的日期。流程是从1900年到目标农历年前一年累加每个农历年的总天数在当前农历年中按顺序累加目标月之前的正常月和闰月加上天数偏移得到距基准日的总偏移用days_to_date转回公历核心代码Date lunar_to_solar(LunarDate lunar) { int offset 0; int i; unsigned int info; for (i 1900; i lunar.year; i) offset lunar_year_days(i); info lunarInfo[lunar.year - 1900]; int leapMonth LEAP_MONTH(info); for (i 1; i lunar.month; i) { offset lunar_month_days(info, i, 0); if (i leapMonth) offset LEAP_MONTH_BIG(info) ? 30 : 29; } if (lunar.isLeap) { if (leapMonth ! lunar.month) { // 如果输入的闰月不是实际闰月按不合法处理 return (Date){0, 0, 0}; } offset LEAP_MONTH_BIG(info) ? 30 : 29; } offset lunar.day - 1; return days_to_date(offset date_to_days(1900, 1, 31)); }这里有个安全校验用户输入的“闰四月”如果在数据表中当年并不闰四月那一定是非法输入直接返回空日期。我之前没加这个判断程序收到异常输入时会算出一个离谱的公历日期后续再做日期比较时就会出大问题。作为API设计宁可显式返回错误也不要给调用方一个“看起来正常但实际是错的”结果。3.2 万年历展示要用到的星期计算万年历展示需要知道某一天是星期几。农历转换本身不涉及星期但如果要把某个公历月的日历打印成表格就一定要算星期。我用的是**蔡勒公式Zellers Congruence**的格里高利历版本直接返回0到6分别代表星期日到星期六int day_of_week(int year, int month, int day) { if (month 3) { month 12; year--; } int h (day (13 * (month 1)) / 5 year year / 4 - year / 100 year / 400) % 7; return (h 6) % 7; // 转成0周日1周一...6周六 }这个公式适用于1582年10月15日之后的格里高利历日期我们的数据范围1900到2100年完全没问题。我特意把返回值标准化成“0代表周日、1代表周一”这种国内常见习惯这样打印日历的时候第一列就是周日。不过不同场景有人喜欢周一开头这个看你自己的需求公式本身不用变只改映射关系就行。万年历的打印逻辑没什么玄学核心是两层循环外层遍历12个月内层遍历当前月的每一天打印公历日期和农历日期。格式上要对齐农历中文“正月初一”“腊月三十”等字样占位不一样我统一用最大宽度格式化避免表格错位。4. 干支、生肖与节气补充4.1 干支纪年到底以谁为界很多代码里生肖和干支直接用公历年份计算比如2024年就直接取地支“辰”输出“龙年”。但严谨的农历万年历年界应该以立春为准而不是正月初一。也就是说2024年2月4日立春之前出生的人在干支纪年上仍然算癸卯年属兔立春之后才算甲辰年属龙。这跟民间用正月初一换属相的习惯是两套体系我自己做日历干支和生肖都按立春换年这样跟传统命理、黄历的算法一致。如果你只做产品展示按正月初一换年也常见但你得在文档里明确说明采用的是哪种口径。干支计算用天干10和地支12的最小公倍数60也就是“六十甲子”。最简单的做法是查表但既然是C语言项目我用公式算#define CYCLE_BASE_YEAR 1900 // 1900年是庚子年 void get_ganzhi(int lunarYear, char *out) { static const char *tiangan[] {甲,乙,丙,丁,戊,己,庚,辛,壬,癸}; static const char *dizhi[] {子,丑,寅,卯,辰,巳,午,未,申,酉,戌,亥}; int index (lunarYear - CYCLE_BASE_YEAR) % 60; if (index 0) index 60; sprintf(out, %s%s, tiangan[index % 10], dizhi[index % 12]); }1900年是庚子年这个大家可以去查任何老黄历都有。所以那一年干支索引是0天干是“庚”地支是“子”。以后每过一年索引加1就能得到完整六十甲子。4.2 节气计算的取舍节气这个东西真要算准了很难。节气是依据太阳在黄道上的位置划分的每15度一个节气所以它本质上是基于地球绕太阳轨道的运动。网上流传的简化公式某节气日期 ≈ (Y × D C) - L / 128D、C是经验系数L是闰年数。这个公式算出来误差有时超过一小时甚至可能差出一天用来做“今天是立春吗”这种展示还行但要做节气交节时刻完全不够用。我这版的做法很务实不自己算节气而是把1900到2100年每年24个节气的日期做成一张小表程序里直接查。这张表的数据量是201年乘以24个也就4824个短整型数占不到10KB Flash对任何现代单片机都不是负担。查表的好处是节气日期直接和权威历书一致不需要引入复杂的天文算法。为什么说是“取舍”因为对于万年历这个需求用户真正关心的是“今天是不是某节气”而不是“节气交节的具体秒”。如果哪天真要精确到分钟那时候再引入VSOP87或者天文年历也不迟。节气表的数据格式我设计成每一年用24个unsigned char表示节气日月份信息固定写在代码里这样每个节气占1字节。有些跨年的节气比如小寒在1月5日它的农历日期可能出现上一年腊月或当年正月这个在打印时不用特殊处理你只要按公历日期把节气名挂上去就行。5. 调试实录与通用避坑指南5.1 跨平台中文输出乱码这个项目输出大量中文农历月份、节气、干支在VSCode的终端里经常出现乱码。根源就是源文件编码和终端编码不一致Windows终端默认GBKVSCode如果按UTF-8保存并且终端没有切到UTF-8打印出来就是一堆乱码。我现在的习惯是源文件统一UTF-8在代码里不依赖中文输出做核心逻辑所有关键的日期运算只用整数。中文只出现在最终展示层。这样就算某个终端乱码功能也是好的你只需要在运行时切换终端代码页。Windows下可以先执行chcp 65001再运行程序如果你需要在老旧终端上稳定显示GBK可以把源文件转成GBK编码但那样做跨平台性就差一些Linux/Mac上反而乱码。最好的办法还是展示层用中性的英文数字接口外面再套一层本地化适配。5.2 数据表边界与数组越界我见过很多初学者拿网上的农历数据表抄过来然后循环里写while (offset lunar_year_days(i))当时i已经跑到2100年数组越界程序也不报错只出莫名其妙的垃圾日期。两个防呆措施数组下标全部用year - 1900并且入口处断言year 1900 year 2100定位农历年的循环里限制i的最大值不超过2099一旦超界就返回错误结构体我做了一个统一的错误处理所有可能失败的转换函数都返回一个带valid标志的结构体而不是返回裸的整型或指针。刚开始写的时候图省事用int返回错误码调用方很快就会忘。现在这个项目里宁可多写几行结构体赋值也要让错误显式可见。5.3 随机抽检用已知日期做回归测试农历算法最怕的就是“大部分时间是对的个别日期是错的”这种错往往出在闰月附近。我的测试方法是准备一批锚点日期比如2000年2月5日农历2000年正月初一2020年5月23日农历闰四月初一2023年3月22日农历闰二月初一2023年1月22日农历正月初一然后用一个测试函数先把公历转农历再把农历结果反向转回公历对比是否还原成原日期。这个是双向转换最有效的自检方式。只要随机抽1000个日期做往返一致性的验证就能把99%的闰月问题揪出来。如果你发现某一天对不上先别怀疑算法用二分法缩小范围然后跟在线日历核对那十几天的数据到底是大小月错了还是闰月错了。我还写过一个小工具把整个数据表从1900年到2100年的所有正月初一日期打印出来跟权威数据源逐条比对一次性验证整张表的正确性。这种回归测试放在make test目标里每次改动后跑一遍心里才踏实。5.4 不要依赖系统时间戳做农历运算最后一个很多人忽略的坑time()返回的时间戳是UTC的转换成本地时间时会受系统时区影响。农历历书严格按东八区时间但你的嵌入式设备如果被设成了UTC或别的时区用localtime()拿到的公历日期可能是前一天或后一天。我在项目里要求所有转换函数只接收“公历年月日”结构体由上层自己去保证这个日期是正确的本地日期。你可以在应用层读取RTC时做一次时区换算但农历算法核心层绝不碰时间戳。这个分层设计在移植到不同硬件平台时省了很多事。我在这版实现里最大的感触是农历万年历真正难的并不是“编程”而是数据结构的约定和边界条件的处理。当你把一年的农历信息压进一个整数把闰月插进正常月序列把基准日的偏移一次一次地减下去这个程序就具备了脱离网络也能稳定运行的价值。如果你也正在写类似的项目我建议你不要一上来就写代码先把1900到2100年的农历数据来源核准把转换的往返一致性测试框架搭好再去填功能页面。后面给设备加显示、加提醒、加打卡记录都只是在这个稳定的核心上做加法而已。本文还有配套的精品资源点击获取