1 | #!/usr/bin/env python |
2 | |
3 | """Check CFC - Check Compile Flow Consistency |
4 | |
5 | This is a compiler wrapper for testing that code generation is consistent with |
6 | different compilation processes. It checks that code is not unduly affected by |
7 | compiler options or other changes which should not have side effects. |
8 | |
9 | To use: |
10 | -Ensure that the compiler under test (i.e. clang, clang++) is on the PATH |
11 | -On Linux copy this script to the name of the compiler |
12 | e.g. cp check_cfc.py clang && cp check_cfc.py clang++ |
13 | -On Windows use setup.py to generate check_cfc.exe and copy that to clang.exe |
14 | and clang++.exe |
15 | -Enable the desired checks in check_cfc.cfg (in the same directory as the |
16 | wrapper) |
17 | e.g. |
18 | [Checks] |
19 | dash_g_no_change = true |
20 | dash_s_no_change = false |
21 | |
22 | -The wrapper can be run using its absolute path or added to PATH before the |
23 | compiler under test |
24 | e.g. export PATH=<path to check_cfc>:$PATH |
25 | -Compile as normal. The wrapper intercepts normal -c compiles and will return |
26 | non-zero if the check fails. |
27 | e.g. |
28 | $ clang -c test.cpp |
29 | Code difference detected with -g |
30 | --- /tmp/tmp5nv893.o |
31 | +++ /tmp/tmp6Vwjnc.o |
32 | @@ -1 +1 @@ |
33 | - 0: 48 8b 05 51 0b 20 00 mov 0x200b51(%rip),%rax |
34 | + 0: 48 39 3d 51 0b 20 00 cmp %rdi,0x200b51(%rip) |
35 | |
36 | -To run LNT with Check CFC specify the absolute path to the wrapper to the --cc |
37 | and --cxx options |
38 | e.g. |
39 | lnt runtest nt --cc <path to check_cfc>/clang \\ |
40 | --cxx <path to check_cfc>/clang++ ... |
41 | |
42 | To add a new check: |
43 | -Create a new subclass of WrapperCheck |
44 | -Implement the perform_check() method. This should perform the alternate compile |
45 | and do the comparison. |
46 | -Add the new check to check_cfc.cfg. The check has the same name as the |
47 | subclass. |
48 | """ |
49 | |
50 | from __future__ import absolute_import, division, print_function |
51 | |
52 | import imp |
53 | import os |
54 | import platform |
55 | import shutil |
56 | import subprocess |
57 | import sys |
58 | import tempfile |
59 | try: |
60 | import configparser |
61 | except ImportError: |
62 | import ConfigParser as configparser |
63 | import io |
64 | |
65 | import obj_diff |
66 | |
67 | def is_windows(): |
68 | """Returns True if running on Windows.""" |
69 | return platform.system() == 'Windows' |
70 | |
71 | class WrapperStepException(Exception): |
72 | """Exception type to be used when a step other than the original compile |
73 | fails.""" |
74 | def __init__(self, msg, stdout, stderr): |
75 | self.msg = msg |
76 | self.stdout = stdout |
77 | self.stderr = stderr |
78 | |
79 | class WrapperCheckException(Exception): |
80 | """Exception type to be used when a comparison check fails.""" |
81 | def __init__(self, msg): |
82 | self.msg = msg |
83 | |
84 | def main_is_frozen(): |
85 | """Returns True when running as a py2exe executable.""" |
86 | return (hasattr(sys, "frozen") or # new py2exe |
87 | hasattr(sys, "importers") or # old py2exe |
88 | imp.is_frozen("__main__")) # tools/freeze |
89 | |
90 | def get_main_dir(): |
91 | """Get the directory that the script or executable is located in.""" |
92 | if main_is_frozen(): |
93 | return os.path.dirname(sys.executable) |
94 | return os.path.dirname(sys.argv[0]) |
95 | |
96 | def remove_dir_from_path(path_var, directory): |
97 | """Remove the specified directory from path_var, a string representing |
98 | PATH""" |
99 | pathlist = path_var.split(os.pathsep) |
100 | norm_directory = os.path.normpath(os.path.normcase(directory)) |
101 | pathlist = [x for x in pathlist if os.path.normpath( |
102 | os.path.normcase(x)) != norm_directory] |
103 | return os.pathsep.join(pathlist) |
104 | |
105 | def path_without_wrapper(): |
106 | """Returns the PATH variable modified to remove the path to this program.""" |
107 | scriptdir = get_main_dir() |
108 | path = os.environ['PATH'] |
109 | return remove_dir_from_path(path, scriptdir) |
110 | |
111 | def flip_dash_g(args): |
112 | """Search for -g in args. If it exists then return args without. If not then |
113 | add it.""" |
114 | if '-g' in args: |
115 | # Return args without any -g |
116 | return [x for x in args if x != '-g'] |
117 | else: |
118 | # No -g, add one |
119 | return args + ['-g'] |
120 | |
121 | def derive_output_file(args): |
122 | """Derive output file from the input file (if just one) or None |
123 | otherwise.""" |
124 | infile = get_input_file(args) |
125 | if infile is None: |
126 | return None |
127 | else: |
128 | return '{}.o'.format(os.path.splitext(infile)[0]) |
129 | |
130 | def get_output_file(args): |
131 | """Return the output file specified by this command or None if not |
132 | specified.""" |
133 | grabnext = False |
134 | for arg in args: |
135 | if grabnext: |
136 | return arg |
137 | if arg == '-o': |
138 | # Specified as a separate arg |
139 | grabnext = True |
140 | elif arg.startswith('-o'): |
141 | # Specified conjoined with -o |
142 | return arg[2:] |
143 | assert grabnext == False |
144 | |
145 | return None |
146 | |
147 | def is_output_specified(args): |
148 | """Return true is output file is specified in args.""" |
149 | return get_output_file(args) is not None |
150 | |
151 | def replace_output_file(args, new_name): |
152 | """Replaces the specified name of an output file with the specified name. |
153 | Assumes that the output file name is specified in the command line args.""" |
154 | replaceidx = None |
155 | attached = False |
156 | for idx, val in enumerate(args): |
157 | if val == '-o': |
158 | replaceidx = idx + 1 |
159 | attached = False |
160 | elif val.startswith('-o'): |
161 | replaceidx = idx |
162 | attached = True |
163 | |
164 | if replaceidx is None: |
165 | raise Exception |
166 | replacement = new_name |
167 | if attached == True: |
168 | replacement = '-o' + new_name |
169 | args[replaceidx] = replacement |
170 | return args |
171 | |
172 | def add_output_file(args, output_file): |
173 | """Append an output file to args, presuming not already specified.""" |
174 | return args + ['-o', output_file] |
175 | |
176 | def set_output_file(args, output_file): |
177 | """Set the output file within the arguments. Appends or replaces as |
178 | appropriate.""" |
179 | if is_output_specified(args): |
180 | args = replace_output_file(args, output_file) |
181 | else: |
182 | args = add_output_file(args, output_file) |
183 | return args |
184 | |
185 | gSrcFileSuffixes = ('.c', '.cpp', '.cxx', '.c++', '.cp', '.cc') |
186 | |
187 | def get_input_file(args): |
188 | """Return the input file string if it can be found (and there is only |
189 | one).""" |
190 | inputFiles = list() |
191 | for arg in args: |
192 | testarg = arg |
193 | quotes = ('"', "'") |
194 | while testarg.endswith(quotes): |
195 | testarg = testarg[:-1] |
196 | testarg = os.path.normcase(testarg) |
197 | |
198 | # Test if it is a source file |
199 | if testarg.endswith(gSrcFileSuffixes): |
200 | inputFiles.append(arg) |
201 | if len(inputFiles) == 1: |
202 | return inputFiles[0] |
203 | else: |
204 | return None |
205 | |
206 | def set_input_file(args, input_file): |
207 | """Replaces the input file with that specified.""" |
208 | infile = get_input_file(args) |
209 | if infile: |
210 | infile_idx = args.index(infile) |
211 | args[infile_idx] = input_file |
212 | return args |
213 | else: |
214 | # Could not find input file |
215 | assert False |
216 | |
217 | def is_normal_compile(args): |
218 | """Check if this is a normal compile which will output an object file rather |
219 | than a preprocess or link. args is a list of command line arguments.""" |
220 | compile_step = '-c' in args |
221 | # Bitcode cannot be disassembled in the same way |
222 | bitcode = '-flto' in args or '-emit-llvm' in args |
223 | # Version and help are queries of the compiler and override -c if specified |
224 | query = '--version' in args or '--help' in args |
225 | # Options to output dependency files for make |
226 | dependency = '-M' in args or '-MM' in args |
227 | # Check if the input is recognised as a source file (this may be too |
228 | # strong a restriction) |
229 | input_is_valid = bool(get_input_file(args)) |
230 | return compile_step and not bitcode and not query and not dependency and input_is_valid |
231 | |
232 | def run_step(command, my_env, error_on_failure): |
233 | """Runs a step of the compilation. Reports failure as exception.""" |
234 | # Need to use shell=True on Windows as Popen won't use PATH otherwise. |
235 | p = subprocess.Popen(command, stdout=subprocess.PIPE, |
236 | stderr=subprocess.PIPE, env=my_env, shell=is_windows()) |
237 | (stdout, stderr) = p.communicate() |
238 | if p.returncode != 0: |
239 | raise WrapperStepException(error_on_failure, stdout, stderr) |
240 | |
241 | def get_temp_file_name(suffix): |
242 | """Get a temporary file name with a particular suffix. Let the caller be |
243 | responsible for deleting it.""" |
244 | tf = tempfile.NamedTemporaryFile(suffix=suffix, delete=False) |
245 | tf.close() |
246 | return tf.name |
247 | |
248 | class WrapperCheck(object): |
249 | """Base class for a check. Subclass this to add a check.""" |
250 | def __init__(self, output_file_a): |
251 | """Record the base output file that will be compared against.""" |
252 | self._output_file_a = output_file_a |
253 | |
254 | def perform_check(self, arguments, my_env): |
255 | """Override this to perform the modified compilation and required |
256 | checks.""" |
257 | raise NotImplementedError("Please Implement this method") |
258 | |
259 | class dash_g_no_change(WrapperCheck): |
260 | def perform_check(self, arguments, my_env): |
261 | """Check if different code is generated with/without the -g flag.""" |
262 | output_file_b = get_temp_file_name('.o') |
263 | |
264 | alternate_command = list(arguments) |
265 | alternate_command = flip_dash_g(alternate_command) |
266 | alternate_command = set_output_file(alternate_command, output_file_b) |
267 | run_step(alternate_command, my_env, "Error compiling with -g") |
268 | |
269 | # Compare disassembly (returns first diff if differs) |
270 | difference = obj_diff.compare_object_files(self._output_file_a, |
271 | output_file_b) |
272 | if difference: |
273 | raise WrapperCheckException( |
274 | "Code difference detected with -g\n{}".format(difference)) |
275 | |
276 | # Clean up temp file if comparison okay |
277 | os.remove(output_file_b) |
278 | |
279 | class dash_s_no_change(WrapperCheck): |
280 | def perform_check(self, arguments, my_env): |
281 | """Check if compiling to asm then assembling in separate steps results |
282 | in different code than compiling to object directly.""" |
283 | output_file_b = get_temp_file_name('.o') |
284 | |
285 | alternate_command = arguments + ['-via-file-asm'] |
286 | alternate_command = set_output_file(alternate_command, output_file_b) |
287 | run_step(alternate_command, my_env, |
288 | "Error compiling with -via-file-asm") |
289 | |
290 | # Compare if object files are exactly the same |
291 | exactly_equal = obj_diff.compare_exact(self._output_file_a, output_file_b) |
292 | if not exactly_equal: |
293 | # Compare disassembly (returns first diff if differs) |
294 | difference = obj_diff.compare_object_files(self._output_file_a, |
295 | output_file_b) |
296 | if difference: |
297 | raise WrapperCheckException( |
298 | "Code difference detected with -S\n{}".format(difference)) |
299 | |
300 | # Code is identical, compare debug info |
301 | dbgdifference = obj_diff.compare_debug_info(self._output_file_a, |
302 | output_file_b) |
303 | if dbgdifference: |
304 | raise WrapperCheckException( |
305 | "Debug info difference detected with -S\n{}".format(dbgdifference)) |
306 | |
307 | raise WrapperCheckException("Object files not identical with -S\n") |
308 | |
309 | # Clean up temp file if comparison okay |
310 | os.remove(output_file_b) |
311 | |
312 | if __name__ == '__main__': |
313 | # Create configuration defaults from list of checks |
314 | default_config = """ |
315 | [Checks] |
316 | """ |
317 | |
318 | # Find all subclasses of WrapperCheck |
319 | checks = [cls.__name__ for cls in vars()['WrapperCheck'].__subclasses__()] |
320 | |
321 | for c in checks: |
322 | default_config += "{} = false\n".format(c) |
323 | |
324 | config = configparser.RawConfigParser() |
325 | config.readfp(io.BytesIO(default_config)) |
326 | scriptdir = get_main_dir() |
327 | config_path = os.path.join(scriptdir, 'check_cfc.cfg') |
328 | try: |
329 | config.read(os.path.join(config_path)) |
330 | except: |
331 | print("Could not read config from {}, " |
332 | "using defaults.".format(config_path)) |
333 | |
334 | my_env = os.environ.copy() |
335 | my_env['PATH'] = path_without_wrapper() |
336 | |
337 | arguments_a = list(sys.argv) |
338 | |
339 | # Prevent infinite loop if called with absolute path. |
340 | arguments_a[0] = os.path.basename(arguments_a[0]) |
341 | |
342 | # Sanity check |
343 | enabled_checks = [check_name |
344 | for check_name in checks |
345 | if config.getboolean('Checks', check_name)] |
346 | checks_comma_separated = ', '.join(enabled_checks) |
347 | print("Check CFC, checking: {}".format(checks_comma_separated)) |
348 | |
349 | # A - original compilation |
350 | output_file_orig = get_output_file(arguments_a) |
351 | if output_file_orig is None: |
352 | output_file_orig = derive_output_file(arguments_a) |
353 | |
354 | p = subprocess.Popen(arguments_a, env=my_env, shell=is_windows()) |
355 | p.communicate() |
356 | if p.returncode != 0: |
357 | sys.exit(p.returncode) |
358 | |
359 | if not is_normal_compile(arguments_a) or output_file_orig is None: |
360 | # Bail out here if we can't apply checks in this case. |
361 | # Does not indicate an error. |
362 | # Maybe not straight compilation (e.g. -S or --version or -flto) |
363 | # or maybe > 1 input files. |
364 | sys.exit(0) |
365 | |
366 | # Sometimes we generate files which have very long names which can't be |
367 | # read/disassembled. This will exit early if we can't find the file we |
368 | # expected to be output. |
369 | if not os.path.isfile(output_file_orig): |
370 | sys.exit(0) |
371 | |
372 | # Copy output file to a temp file |
373 | temp_output_file_orig = get_temp_file_name('.o') |
374 | shutil.copyfile(output_file_orig, temp_output_file_orig) |
375 | |
376 | # Run checks, if they are enabled in config and if they are appropriate for |
377 | # this command line. |
378 | current_module = sys.modules[__name__] |
379 | for check_name in checks: |
380 | if config.getboolean('Checks', check_name): |
381 | class_ = getattr(current_module, check_name) |
382 | checker = class_(temp_output_file_orig) |
383 | try: |
384 | checker.perform_check(arguments_a, my_env) |
385 | except WrapperCheckException as e: |
386 | # Check failure |
387 | print("{} {}".format(get_input_file(arguments_a), e.msg), file=sys.stderr) |
388 | |
389 | # Remove file to comply with build system expectations (no |
390 | # output file if failed) |
391 | os.remove(output_file_orig) |
392 | sys.exit(1) |
393 | |
394 | except WrapperStepException as e: |
395 | # Compile step failure |
396 | print(e.msg, file=sys.stderr) |
397 | print("*** stdout ***", file=sys.stderr) |
398 | print(e.stdout, file=sys.stderr) |
399 | print("*** stderr ***", file=sys.stderr) |
400 | print(e.stderr, file=sys.stderr) |
401 | |
402 | # Remove file to comply with build system expectations (no |
403 | # output file if failed) |
404 | os.remove(output_file_orig) |
405 | sys.exit(1) |
406 | |