获取程序的编译时间

  1. 1. 日常新需求
  2. 2. 实现
  3. 3. Deterministic Build 来背锅

日常新需求

新的需求又来了。这次是程序在编译后6个月拒绝启动。BETA性质的软件都有类似的需求。但大部分软件要么是启动时检查更新,要么是联网判断是否过期。对于我们现在做的这个小工具太小题大做了。根据编译时间判断过期的需求看似奇葩,也是有点道理的。

实现

遇事不决问SO,我们输入”C# get compile time”找到的第一个问题就是了。

方法有很多,大致分为这几类。

  1. 读取PE头部时间戳
  2. 添加Build Task将编译时间以资源嵌入程序集
  3. 读取文件创建时间

严格意义上说,文件创建时间与文件系统相关,依赖程序外部,碰到不保留创建时间的操作就只能干瞪眼了,所以排除掉。

而添加Build Task又需要程序读取资源,反序列化云云,比较麻烦。
因此主要关注1中的方法。

.NET的程序集都包含PE头部。先来一张图感受一下。

我们需要的字段在COFFHeader的TimeDateStamp处。

需要注意的一点是,图中的偏移量是相对PE头的,而在PE头部之前还有DOS头部。

PE Header

再来看看StackOverflow上的答案,是不是比较直观呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// see https://stackoverflow.com/a/1601079
struct _IMAGE_FILE_HEADER
{
public ushort Machine;
public ushort NumberOfSections;
public uint TimeDateStamp;
public uint PointerToSymbolTable;
public uint NumberOfSymbols;
public ushort SizeOfOptionalHeader;
public ushort Characteristics;
};
static DateTime GetBuildDateTime(Assembly assembly)
{
var path = assembly.GetName().CodeBase;
if (File.Exists(path))
{
var buffer = new byte[Math.Max(Marshal.SizeOf(typeof(_IMAGE_FILE_HEADER)), 4)];
using (var fileStream = new FileStream(path, FileMode.Open,FileAccess.Read))
{
fileStream.Position = 0x3C;
fileStream.Read(buffer, 0, 4);
fileStream.Position = BitConverter.ToUInt32(buffer, 0); // COFF header offset
fileStream.Read(buffer, 0, 4); // "PE\0\0"
fileStream.Read(buffer, 0, buffer.Length);
}
var pinnedBuffer = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
var coffHeader = (_IMAGE_FILE_HEADER)Marshal.PtrToStructure(pinnedBuffer.AddrOfPinnedObject(),typeof(_IMAGE_FILE_HEADER));
return TimeZone.CurrentTimeZone.ToLocalTime(new DateTime(1970, 1, 1) + new TimeSpan(coffHeader.TimeDateStamp * TimeSpan.TicksPerSecond));
}
finally
{
pinnedBuffer.Free();
}
}
return new DateTime();
}

Ox3C位置读到PE头部的位置后Seek到该位置,读取内容后将其转换为自定义的_IMAGE_FILE_HEADER结构,读取TimeDateStamp即可。

直接运行的结果是1900/1/1 12:00:00,不对。原因是AssemblyName.CodeBase属性返回的并不是程序集所在路径,而是File scheme的URI file:\\\c:\MyDirectory\MyAssemlby.exe。使用AssemblyName后问题解决。
Ox3C位置读到PE头部的位置后Seek到该位置,读取内容后将其转换为自定义的_IMAGE_FILE_HEADER.TimeDateStamp,读取TimeDateStamp即可。
PE头部的TimeDateStamp字段是从unix epoll算起的,8102年的.NET中也有专门处理这里情况的DateTimeOffSet,而异常情况我们完全可以返回null
修改后代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
static DateTimeOffset? GetBuildDateTime(this Assembly assembly)
{
var path = assembly.Location;
if (File.Exists(path))
{
var buffer = new byte[Math.Max(Marshal.SizeOf(typeof(_IMAGE_FILE_HEADER)), 4)];
using (var fileStream = new FileStream(path, FileMode.Open, FileAccess.Read))
{
fileStream.Position = 0x3C;
fileStream.Read(buffer, 0, 4);
fileStream.Position = BitConverter.ToUInt32(buffer, 0); // COFF header offset
fileStream.Read(buffer, 0, 4); // "PE\0\0"
fileStream.Read(buffer, 0, buffer.Length);
}
var pinnedBuffer = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
var coffHeader = (_IMAGE_FILE_HEADER)Marshal.PtrToStructure(pinnedBuffer.AddrOfPinnedObject(), typeof(_IMAGE_FILE_HEADER));
return DateTimeOffset.FromUnixTimeSeconds(coffHeader.TimeDateStamp);
}
finally
{
pinnedBuffer.Free();
}
}
else
{
return null;
}
}

修改后发现返回的是一个在2090年之后的日期,还是不对。

Deterministic Build 来背锅

这次的原因是MS在某个Roslyn版本中默认开启了Deterministic Build

….. The /deterministic flag causes the compiler to emit the exact same EXE / DLL, byte for byte, when given the same inputs.

既然输入相同输出必定相同,那可能会变的部分就只能固定下来了。例如时间戳。

… the MVID, PDB ID and Timestamp are the core issues to solve for deterministic builds.

MS选了一个比较折中的方案——由文件内容计算

Why not just use all 0s for the timestamp?

This is actually how the original implementation of determinism functioned in the compiler. Unfortunately it turned out there were a lot of tools we used in our internal process that validated the timestamp. They got a bit cranky when the discovered binaries claiming to be written in 1970, over 25 years before .NET was even invented. The practice of validating the time stamp is questionable but given tools were doing it there was a significant back compat risk. Hence we moved to the current computed value and haven’t seen any issues since then.

所以我们得到一个奇怪的值也是Work as expected了🙃

解决办法非常简单,在.csproj中将

1
<deterministic>true</deterministic>

改为

1
<deterministic>false</deterministic>

即可。
需要注意的是,Deterministic Build在.NET Core上默认开启,要使PE头部的TimeDateStamp有意义需要将其关闭。而在.NET Framework上则是只有在VS2017的某个特定版本后新建的工程才会开启。

题外话,这个默认开启的Deterministic Build还搞出了其他幺蛾子,例如这里