在做协议解析、日志文件逆向或者读取某些自定义格式文件时,经常会碰到时间以二进制字节的形式存放在数据里。这些字节既不能直接打印,也不能直接参与计算,必须先按规则解包,再转换成时间戳或者格式化的日期字符串。Python在这方面提供了非常顺手的工具链,核心是struct模块配合time模块和datetime模块,整个流程理清楚之后,转换工作其实非常简单。下面把整个转换链条拆开讲透。

一、为什么时间会以二进制形式存储
先搞清楚来源,处理起来才不会迷茫。很多系统在存储时间时不会存字符串,而是存一个整数,比如从1970年1月1日零点到某个时刻所经过的秒数,也就是所谓的Unix时间戳。为了节省空间或者满足对齐要求,这个整数可能只占4个字节或者8个字节,直接以原始字节的形式写进文件或网络报文中。
举个例子,一个4字节的时间戳用十六进制表示可能是b'\x65\x0f\x2c\x00',如果直接按字符串打印出来就是一堆乱码,因为它的本质是无符号32位整数。再比如某些老系统或嵌入式设备,为了省空间只存2个字节,以1980年或者2000年为基准记录偏移量,这类数据解析时还要额外加上基准时间。
另外要注意一点,Java、JavaScript等语言的时间戳通常是毫秒级的,而C语言和Python默认的time.time()返回的是秒级。如果拿到的二进制数据来自Java系统,解包出来的整数直接扔给datetime.fromtimestamp()就会得到一个莫名其妙的日期,这是新手最容易翻车的地方之一。
二、用struct模块解包二进制数据
struct模块是Python处理二进制数据的主力工具,它的unpack函数可以按照指定的格式字符串,把bytes数据还原成Python的基本类型。格式字符串由两部分组成:字节序前缀和类型字符。
字节序前缀常见的有三种:>表示大端序(高位字节在前),<表示小端序(低位字节在前),=表示使用系统原生字节序。网络传输的数据一般是大端序,也就是所谓的网络字节序,而x86架构的机器内部是小端序。类型字符方面,I代表4字节无符号整数,i代表4字节有符号整数,Q代表8字节无符号整数,H代表2字节无符号整数。
看一个具体例子,把一段4字节的二进制数据解包成无符号整数:
import struct
data = b'\x65\x0f\x2c\x00' # 一段4字节的二进制数据
# 按大端序解包为无符号32位整数
timestamp = struct.unpack('>I', data)[0]
print(timestamp) # 输出类似 1694728192
# 按小端序解包,结果完全不同
timestamp_le = struct.unpack('<I', data)[0]
print(timestamp_le)
同一个字节数组,用大端序和小端序解出来的数值差别巨大,所以拿到数据后必须先确认字节序。判断方法很简单:如果知道这份数据大概对应的时间范围,可以两种都试一下,看哪个结果落在合理的区间内。比如解出来一个是1694728192(对应2023年),另一个是261491541(对应1978年),显然前者更可能是正确答案。
三、时间戳与日期时间的相互转换
解包得到整数之后,接下来的工作就是把时间戳变成人类可读的日期。这里有两个模块可以用,各有分工。time模块偏底层,适合快速转换;datetime模块功能更全,方便做后续的日期运算和格式化。
用time模块转换的写法如下:
import struct
import time
data = b'\x65\x0f\x2c\x00'
timestamp = struct.unpack('>I', data)[0]
# 转成本地时间的结构化对象
local_time = time.localtime(timestamp)
print(local_time.tm_year, local_time.tm_mon, local_time.tm_mday)
# 直接格式化成字符串
print(time.strftime('%Y-%m-%d %H:%M:%S', local_time))
如果用datetime模块,代码更加直观,而且得到的datetime对象支持加减运算、时区处理等高级操作:
import struct
from datetime import datetime
data = b'\x65\x0f\x2c\x00'
timestamp = struct.unpack('>I', data)[0]
# 时间戳转datetime对象(本地时区)
dt = datetime.fromtimestamp(timestamp)
print(dt.strftime('%Y年%m月%d日 %H时%M分%S秒'))
# 反向操作:datetime对象转回时间戳
ts = dt.timestamp()
print(ts)
有一个细节值得注意,datetime.fromtimestamp()默认使用本地时区,如果需要UTC时间,应该用datetime.utcfromtimestamp()或者传入timezone.utc参数。处理跨时区系统的数据时,时区搞错会导致时间差好几个小时,排查起来很费劲,建议一开始就明确时区语义。
四、常见坑点与特殊场景处理
第一个坑是毫秒级时间戳。前面提到过,Java系的时间戳是毫秒级的,8字节数据解包后得到的整数会非常大,比如1700000000000这种量级。直接转换会抛出异常或者得到错误日期,正确做法是先除以1000:
import struct
from datetime import datetime
data = b'\x00\x01\x8a\xc3\xfa\x8e\x80\x00' # 8字节毫秒级时间戳
ms_timestamp = struct.unpack('>Q', data)[0]
# 毫秒转秒,保留毫秒精度
seconds = ms_timestamp / 1000
dt = datetime.fromtimestamp(seconds)
print(dt)
第二个坑是有符号与无符号的问题。某些系统的时间字段是32位有符号整数,最大只能表示到2038年1月19日,也就是所谓的2038问题。如果用I(无符号)去解一个实际存储了负数的有符号字段,数值会变成一个巨大的正数,转换出来的日期完全不对。遇到可疑结果时,可以换成i再解一次对比。
第三个坑是短字节偏移型时间。不少嵌入式设备或者老格式文件只用2个字节存时间,以某个固定日期为基准。比如FAT文件系统的时间戳以1980年为基准,其中还把年、月、日、时、分、秒压缩进16位里,这种情况就不能简单解包成整数,而要按位运算拆分:
import struct
from datetime import datetime
# FAT文件系统的32位时间戳(日期部分16位 + 时间部分16位)
raw = b'\x5a\x21\x6b\x0c'
date_part, time_part = struct.unpack('<HH', raw)
# 按位拆分:年占7位,月4位,日5位
year = (date_part >> 9) + 1980
month = (date_part >> 5) & 0x0F
day = date_part & 0x1F
# 时5位,分6位,秒以2秒为单位占5位
hour = time_part >> 11
minute = (time_part >> 5) & 0x3F
second = (time_part & 0x1F) * 2
dt = datetime(year, month, day, hour, minute, second)
print(dt)
五、封装一个通用的转换函数
实际项目中,如果需要频繁处理不同规格的二进制时间数据,建议封装一个通用函数,把字节序、数据宽度、时间单位这些变量都做成参数,代码会清爽很多:
import struct
from datetime import datetime
def binary_to_datetime(data, byteorder='>', size=4, signed=False, unit='s'):
"""把二进制时间数据转换成datetime对象"""
# 根据宽度选择类型字符
type_char = {4: 'I', 8: 'Q', 2: 'H'}[size]
if signed:
type_char = type_char.lower()
timestamp = struct.unpack(byteorder + type_char, data)[0]
# 毫秒时间戳需要换算成秒
if unit == 'ms':
timestamp = timestamp / 1000
return datetime.fromtimestamp(timestamp)
# 测试
result = binary_to_datetime(b'\x65\x0f\x2c\x00')
print(result)
这个函数覆盖了最常见的几种情况,遇到特殊编码比如上面说的FAT压缩格式,再单独写解析逻辑即可。总结一下整个转换流程:先用struct按正确的字节序和宽度解包成整数,判断时间单位是秒还是毫秒,必要时做换算,最后交给datetime模块转成可读格式。把字节序、符号位、时间单位这三个关键点确认清楚,二进制时间数据的转换基本就不会出错了。
Python二进制转换时间戳转换struct模块修改时间:2026-09-10 11:52:47