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