printf - A speculative implementation (part 2)
March 4, 2023
printf - A speculative implementation (part 2)
Link to the previous post: part 1
In the previous post, I have shown a diagram describing a pobssible high-level mechanism of calling printf
. I want to iterate that this is all my speculation. In this post, I will implement this approach.
The full code can be found on my github here: HuyDNA/nonvariadic-printf
API used in this implementation
align
from<memory>
void* align( std::size_t alignment,
std::size_t size,
void*& ptr,
std::size_t& space );
We needn't care too much about the size
and space
parameter here. If size
is set to 0
and space
references a very large number, this function will align ptr
by the specified alignment
.
There's one pitfall: If ptr
is already aligned, calling this function will set ptr
to the next aligned address.
fputs
from<cstdio>
int fputs( const char* str, std::FILE* stream );
Write a null-terminated string to an output stream.
fwrite
from<cstdio>
std::size_t fwrite( const void* buffer, std::size_t size, std::size_t count, std::FILE* stream );
Write up to count
binary objects from the given array buffer to the output stream stream.
putchar
from<cstdio>
int putchar( int ch );
Write a character ch
to stdout
.
My implementation of printf
My implementation of printf
(called my_printf
) is as follows.
#include <cstdio>
#include <cstring>
#include <string>
#include <climits>
#include <stdexcept>
#include <memory>
#include "my_printf.h"
void _my_printf_(const char* format, void* buffer) {
void* p_last = buffer;
const char* format_pos = format;
int length = 0;
for (int i = 0; format[i] != '\\0'; ++i) {
if (format[i] != '%') {
++length;
}
else {
fwrite((const void*) format_pos, length, sizeof(char), stdout);
format_pos += length + 2;
length = 0;
++i;
//The following line is to deal with a pitfall: std::align would return the next pointer if the pointer is already aligned
p_last = (char*)p_last - 1; // Note this
if (format[i] == '\\0') {
putchar('%');
break;
}
else if (format[i] == 'd') {
if (!__realign(p_last, int))
throw std::runtime_error("Buffer overloaded");
int num = *(int*)p_last;
fputs(std::to_string(num).c_str(), stdout);
p_last = (int*)p_last + 1;
}
else if (format[i] == 'f') {
if (!__realign(p_last, double))
throw std::runtime_error("Buffer overloaded");
double num = *(double*)p_last;
fputs(std::to_string(num).c_str(), stdout);
p_last = (double*)p_last + 1;
}
else if (format[i] == 's') {
if (!__realign(p_last, const char*))
throw std::runtime_error("Buffer overloaded");
const char* s = *(const char**)p_last;
fputs(s, stdout);
p_last = (char**)p_last + 1;
}
else if (format[i] == 'p') {
if (!__realign(p_last, const void*))
throw std::runtime_error("Buffer overloaded");
const void* p = *(const void**)p_last;
long long num = (std::size_t)p;
fputs(std::to_string(num).c_str(), stdout);
}
else {
throw std::runtime_error("Unknown format specifier");
}
}
}
fwrite(format_pos, length, sizeof(char), stdout);
}
This strongly resembles the variadic version.
Notice how I have to move p_last
back by 1
before calling __realign
. Due to the pitfall mentioned above, this is to ensure that p_last
points to the right address even if it's already aligned.
At first sight, the overwhelming amount of c-style casts (which perform similarly to reinterpret_cast
in this case) may look intimidating. However, if you have wrapped your head around the example in the previous post, I think this should be digestible.
You can compile it to an object file.
g++ -o my-printf.o -c my-printf.cpp
Writing a pre-preprocessor for my-printf
This is to simulate how a compiler could transform the client code before compiling.
In this section, I write a simple python script which make use of regex to detect calls to my-printf
and add code that handles writing parameters to buffer in the client code.
import sys
import re
printfPattern = re.compile(r"my_printf\\s*\\(([^,;()]*)((?:,[^,;()]*)*)\\)\\s*;", re.MULTILINE | re.DOTALL)
def printfSubstitute(match):
def getWriteParamString(param):
return f"""
p_last = (char*)p_last - 1; // avoid the std::align pitfall
__realign(p_last, decltype({param} + 0));
*(decltype({param} + 0)*)p_last = {param};
p_last = (decltype({param} + 0)*)p_last + 1;
"""
statement = match.group(0)
print("Detected: " + statement)
formatString = match.group(1)
params = match.group(2).split(',')[1:]
paramWriteStatements = ''.join([getWriteParamString(param) for param in params])
return f"""{{
void* __internal_buffer = malloc(10000);
void* p_last = __internal_buffer;
{paramWriteStatements}
_my_printf_({formatString}, __internal_buffer);
free(__internal_buffer);
}}
"""
if __name__ == '__main__':
if len(sys.argv) < 2:
exit("Error: At least one source file should be applied.")
sourceNameList = sys.argv[1:]
for sourceName in sourceNameList:
sourceContent = ""
with open(sourceName, 'r') as sourceFile:
sourceContent = sourceFile.read()
sourceContent = printfPattern.sub(printfSubstitute, sourceContent)
with open(f"{sourceName[:-4]}.output.cpp", 'w') as sourceFile:
sourceFile.write(sourceContent)
I name the script pre-preprocessor.py
and it accepts input source files as arguments. As a result, it then transforms the source files and write the intermediate outputs to files with the extension .output.cpp
.
For example, if you have file named main.cpp
, you can run
python3 pre-preprocessor.py main.cpp
You will notice a main.output.cpp
file has been created -- inspect the file and you will see what the script does!
You should notice that the code injected is very similar to the example in the previous post.
After feeding the source code to the script, you can finally compile the generated code.
g++ -o main main.output.cpp my-printf.o
Additionally, in the repo I have prepared a Makefile
for you. You can follow the steps in there to see how it plays out.
Does the real compiler really go through all these hassle to get printf
to run !?
You couble be now understandably wondering why printf
get all these special treatment. Well -- actually, I think it's the variadic (...
) functions that get the special treatment, not just printf
. The laid-out scheme in these two posts lend itself well to any variadic functions.
That is, there's probably an internal buffer to where the parameters are written to. The macro va_arg
is probably used to interpret the bytes of this internal buffer as some type & and move the internal p_last
pointer.
In conclusion, implementations of variadic functions may vary, but one thing for sure is there's no magic in this like anything else in the world of computers & programming languages.
I hope that you have already come up with a scheme for handling variadic functions of your own by now!
See you in another post.